Merge branch 'main' into bulk-task-editing
This commit is contained in:
commit
59d7e771fa
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
name: api-v2-routes
|
||||
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Adding /api/v2 routes for a CRUDable resource
|
||||
|
||||
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
|
||||
|
||||
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
|
||||
|
||||
## Prerequisite: the model must be CRUDable
|
||||
|
||||
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
|
||||
|
||||
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
|
||||
|
||||
```go
|
||||
// The title of the label. You'll see this one on tasks associated with it.
|
||||
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
|
||||
```
|
||||
|
||||
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
|
||||
|
||||
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
|
||||
|
||||
```go
|
||||
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
|
||||
```
|
||||
|
||||
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Create `pkg/routes/api/v2/<resource>.go`
|
||||
|
||||
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
|
||||
|
||||
```go
|
||||
// Element type matches what models.<Model>.ReadAll returns; extra fields
|
||||
// tagged json:"-" keep the wire shape identical to the plain model.
|
||||
type fooListBody struct {
|
||||
Body Paginated[*models.Foo]
|
||||
}
|
||||
|
||||
func RegisterFooRoutes(api huma.API) {
|
||||
tags := []string{"foos"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "foos-list",
|
||||
Summary: "List foos",
|
||||
Description: "Returns the foos the authenticated user has access to, paginated.",
|
||||
Method: http.MethodGet, Path: "/foos", Tags: tags,
|
||||
}, foosList)
|
||||
Register(api, huma.Operation{OperationID: "foos-read", Summary: "Get a foo", Description: "...", Method: http.MethodGet, Path: "/foos/{id}", Tags: tags}, foosRead)
|
||||
Register(api, huma.Operation{OperationID: "foos-create", Summary: "Create a foo", Description: "...", Method: http.MethodPost, Path: "/foos", Tags: tags}, foosCreate)
|
||||
Register(api, huma.Operation{OperationID: "foos-update", Summary: "Update a foo", Description: "...", Method: http.MethodPut, Path: "/foos/{id}", Tags: tags}, foosUpdate)
|
||||
Register(api, huma.Operation{OperationID: "foos-delete", Summary: "Delete a foo", Description: "...", Method: http.MethodDelete, Path: "/foos/{id}", Tags: tags}, foosDelete)
|
||||
}
|
||||
```
|
||||
|
||||
Use the package's `Register` wrapper, **not** `huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
|
||||
|
||||
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
|
||||
|
||||
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
|
||||
|
||||
### 2. Write the handlers
|
||||
|
||||
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
|
||||
|
||||
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice** — `result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
|
||||
```go
|
||||
items, ok := result.([]*models.Foo)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
|
||||
}
|
||||
return &fooListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
```
|
||||
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
|
||||
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
|
||||
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
|
||||
- **Delete** returns `*emptyBody`.
|
||||
|
||||
### 3. Self-register the resource
|
||||
|
||||
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
|
||||
|
||||
```go
|
||||
func init() { AddRouteRegistrar(RegisterFooRoutes) }
|
||||
|
||||
func RegisterFooRoutes(api huma.API) { ... }
|
||||
```
|
||||
|
||||
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
|
||||
|
||||
Notes:
|
||||
|
||||
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
|
||||
- **Config-gated resources check the flag inside the registrar.** `RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
|
||||
- **AutoPatch is automatic.** `RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
|
||||
|
||||
## REST verb conventions (v2 inverts v1)
|
||||
|
||||
| Operation | v1 | v2 |
|
||||
|---|---|---|
|
||||
| create | PUT | **POST** |
|
||||
| update | POST | **PUT** (and PATCH) |
|
||||
| read / read-all / delete | GET / GET / DELETE | same |
|
||||
|
||||
## Non-CRUDable / custom routes
|
||||
|
||||
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
|
||||
|
||||
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
|
||||
```go
|
||||
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
|
||||
a, err := authFromCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
t := &models.Task{ID: in.ID}
|
||||
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if !can {
|
||||
return nil, huma.Error403Forbidden("forbidden")
|
||||
}
|
||||
// ... do the work against s ...
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &singleBody[models.Task]{Body: t}, nil
|
||||
}
|
||||
```
|
||||
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
|
||||
|
||||
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
|
||||
|
||||
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
|
||||
|
||||
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
|
||||
|
||||
## What's automatic — do NOT hand-roll
|
||||
|
||||
- **PATCH** — `EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
|
||||
- **API token permissions** — `collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
|
||||
- **Security schemes** — `JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
|
||||
- **Error shape** — `translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
|
||||
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
|
||||
|
||||
## Anti-patterns (these get flagged)
|
||||
|
||||
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
|
||||
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
|
||||
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
|
||||
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
|
||||
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
|
||||
- Unquoted ETag in the response header.
|
||||
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
|
||||
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
|
||||
|
||||
## Tests (mandatory)
|
||||
|
||||
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
|
||||
|
||||
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
|
||||
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
|
||||
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
|
||||
|
||||
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:** `mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
|
||||
|
||||
## Related
|
||||
|
||||
- `crudable` skill — the model-layer prerequisite
|
||||
- `pkg/routes/api/v2/labels.go` — reference resource
|
||||
- `pkg/routes/api/v2/{types,errors,huma}.go` — shared envelopes, bridge, config
|
||||
- `pkg/web/handler/core.go` — the `Do*` functions handlers call
|
||||
33
AGENTS.md
33
AGENTS.md
|
|
@ -11,12 +11,24 @@ The project consists of:
|
|||
- `desktop/` – Electron wrapper application
|
||||
- `docs/` – Documentation website
|
||||
|
||||
## API Version Policy — new work goes to /api/v2
|
||||
|
||||
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
|
||||
|
||||
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
|
||||
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
|
||||
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
|
||||
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
|
||||
|
||||
If a task says "add an endpoint for X" without naming a version, it means v2.
|
||||
|
||||
## Skills
|
||||
|
||||
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
|
||||
|
||||
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
|
||||
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
|
||||
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
|
||||
|
||||
## Plans and Worktrees
|
||||
|
||||
|
|
@ -172,11 +184,10 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
### Adding New Features
|
||||
|
||||
**Backend Changes:**
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>`
|
||||
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
|
||||
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
|
||||
3. Create/update services in `pkg/services/` for complex business logic
|
||||
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
|
||||
5. Update Swagger annotations
|
||||
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
|
||||
|
||||
**Frontend Changes:**
|
||||
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
|
||||
|
|
@ -192,10 +203,11 @@ Modern Vue 3 composition API application with TypeScript:
|
|||
4. Update TypeScript interfaces in frontend `src/modelTypes/`
|
||||
|
||||
### API Development
|
||||
- All API endpoints follow RESTful conventions under `/api/v1/`
|
||||
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
|
||||
- Implement proper permissions checking using the Permissions interface
|
||||
- Add Swagger annotations for automatic documentation generation
|
||||
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
|
||||
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
|
||||
- Both versions reuse the generic `pkg/web/handler/` `Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
|
||||
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
|
||||
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
|
||||
|
||||
### Testing
|
||||
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
|
||||
|
|
@ -250,6 +262,8 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
|
|||
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
|
||||
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
|
||||
|
||||
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
|
||||
|
||||
## Key Files and Conventions
|
||||
|
||||
**Configuration:**
|
||||
|
|
@ -261,12 +275,13 @@ After adjusting the source string, you need to call the respective translation l
|
|||
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
|
||||
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
|
||||
- Follow existing patterns for consistency
|
||||
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
|
||||
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
|
||||
|
||||
**Naming Conventions:**
|
||||
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
|
||||
- Vue: PascalCase for components, camelCase for composables
|
||||
- API endpoints: kebab-case in URLs, camelCase in JSON
|
||||
- API endpoints: kebab-case in URLs, snake_case in JSON
|
||||
|
||||
**Permissions and Permissions:**
|
||||
- Always implement Permissions interface for new models
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ WORKDIR /app/vikunja
|
|||
ENTRYPOINT [ "/app/vikunja/vikunja" ]
|
||||
EXPOSE 3456
|
||||
|
||||
COPY --from=apibuilder --chown=1000:1000 /tmp /tmp
|
||||
COPY --from=apibuilder --chown=1000:1000 --chmod=1777 /tmp /tmp
|
||||
|
||||
USER 1000
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
rc-update add vikunja default
|
||||
|
||||
# Fix the config to contain proper values
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
systemctl enable vikunja.service
|
||||
|
||||
# Fix the config to contain proper values
|
||||
NEW_SECRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
NEW_SECRET=$(head -c 512 /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
sed -i "s/<jwt-secret>/$NEW_SECRET/g" /etc/vikunja/config.yml
|
||||
sed -i "s/<rootpath>/\/opt\/vikunja\//g" /etc/vikunja/config.yml
|
||||
sed -i "s/path: \"\.\/vikunja.db\"/path: \"\\/opt\/vikunja\/vikunja.db\"/g" /etc/vikunja/config.yml
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
|
|
@ -397,7 +397,11 @@ function toggleQuickEntry() {
|
|||
// ─── System tray ─────────────────────────────────────────────────────
|
||||
function setupTray() {
|
||||
if (!tray) {
|
||||
const iconPath = path.join(__dirname, 'build', 'icon.png')
|
||||
// NOTE: load the icon from the app root, not build/. The build/ directory is
|
||||
// electron-builder's buildResources dir and is NOT packaged into the app, so
|
||||
// referencing build/icon.png here works in dev but yields an empty tray icon
|
||||
// in packaged releases (see issue #2668).
|
||||
const iconPath = path.join(__dirname, 'icon.png')
|
||||
const icon = nativeImage.createFromPath(iconPath).resize({width: 16, height: 16})
|
||||
tray = new Tray(icon)
|
||||
tray.setToolTip('Vikunja')
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"electron": "40.10.2",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder": "26.15.2",
|
||||
"unzipper": "0.12.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -23,7 +23,6 @@
|
|||
// It has to be the full url, including the last /api/v1 part and port.
|
||||
// You can change this if your api is not reachable on the same port as the frontend.
|
||||
window.API_URL = '/api/v1'
|
||||
window.ALLOW_ICON_CHANGES = true
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@
|
|||
"@tiptap/vue-3": "3.17.0",
|
||||
"@vueuse/core": "14.1.0",
|
||||
"@vueuse/router": "14.1.0",
|
||||
"axios": "1.15.2",
|
||||
"axios": "1.16.0",
|
||||
"blurhash": "2.0.5",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"change-case": "5.4.4",
|
||||
|
|
@ -114,35 +114,35 @@
|
|||
"@tsconfig/node24": "24.0.4",
|
||||
"@types/codemirror": "5.60.17",
|
||||
"@types/is-touch-device": "1.0.3",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/node": "24.13.1",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||
"@typescript-eslint/parser": "8.60.1",
|
||||
"@vitejs/plugin-vue": "6.0.7",
|
||||
"@vue/eslint-config-typescript": "14.7.0",
|
||||
"@vue/test-utils": "2.4.10",
|
||||
"@vue/eslint-config-typescript": "14.8.0",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"@vueuse/shared": "14.3.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
"browserslist": "4.28.2",
|
||||
"caniuse-lite": "1.0.30001793",
|
||||
"caniuse-lite": "1.0.30001797",
|
||||
"csstype": "3.2.3",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-depend": "1.5.0",
|
||||
"eslint-plugin-vue": "10.9.1",
|
||||
"happy-dom": "20.9.0",
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"happy-dom": "20.10.2",
|
||||
"histoire": "1.0.0-beta.1",
|
||||
"otplib": "12.0.1",
|
||||
"postcss": "8.5.15",
|
||||
"postcss-easing-gradients": "3.0.1",
|
||||
"postcss-html": "1.8.1",
|
||||
"postcss-preset-env": "11.3.0",
|
||||
"rollup": "4.60.4",
|
||||
"rollup": "4.61.1",
|
||||
"rollup-plugin-visualizer": "6.0.11",
|
||||
"sass-embedded": "1.100.0",
|
||||
"stylelint": "17.12.0",
|
||||
"stylelint": "17.13.0",
|
||||
"stylelint-config-property-sort-order-smacss": "10.0.0",
|
||||
"stylelint-config-recommended-vue": "1.6.1",
|
||||
"stylelint-config-standard-scss": "17.0.0",
|
||||
|
|
@ -150,12 +150,12 @@
|
|||
"tailwindcss": "4.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"unplugin-inject-preload": "3.0.0",
|
||||
"vite": "7.3.3",
|
||||
"vite": "7.3.5",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-vue-devtools": "8.1.2",
|
||||
"vite-svg-loader": "5.1.1",
|
||||
"vitest": "4.1.7",
|
||||
"vue-tsc": "3.3.2",
|
||||
"vitest": "4.1.8",
|
||||
"vue-tsc": "3.3.4",
|
||||
"wait-on": "9.0.10",
|
||||
"workbox-cli": "7.4.1",
|
||||
"ws": "8.21.0"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -36,4 +36,18 @@ describe('DatepickerWithRange predefined ranges', () => {
|
|||
const last = wrapper.emitted('update:modelValue')?.pop()?.[0]
|
||||
expect(last).toEqual({dateFrom: 'now/M-1M', dateTo: 'now/M'})
|
||||
})
|
||||
|
||||
// A cleared range (the Custom option) comes back as null via v-model; the
|
||||
// modelValue watcher must coerce it, not call null.toISOString().
|
||||
it('accepts a null modelValue without crashing', async () => {
|
||||
const wrapper = mountPicker()
|
||||
await wrapper.setProps({modelValue: {dateFrom: 'now/w', dateTo: 'now/w+1w'}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('now/w')
|
||||
|
||||
await wrapper.setProps({modelValue: {dateFrom: null, dateTo: null}})
|
||||
await wrapper.vm.$nextTick()
|
||||
expect((wrapper.vm as any).from).toBe('')
|
||||
expect((wrapper.vm as any).to).toBe('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -114,16 +114,17 @@ import DatemathHelp from '@/components/date/DatemathHelp.vue'
|
|||
import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
||||
|
||||
const props = defineProps<{
|
||||
// null for a side that's been cleared (the Custom option) — emitted, so accepted too.
|
||||
modelValue: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string,
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null,
|
||||
},
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: {
|
||||
dateFrom: Date | string,
|
||||
dateTo: Date | string
|
||||
dateFrom: Date | string | null,
|
||||
dateTo: Date | string | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
|
|
@ -149,8 +150,8 @@ const to = ref('')
|
|||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : newValue.dateFrom.toISOString()
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : newValue.dateTo.toISOString()
|
||||
from.value = typeof newValue.dateFrom === 'string' ? newValue.dateFrom : (newValue.dateFrom?.toISOString() ?? '')
|
||||
to.value = typeof newValue.dateTo === 'string' ? newValue.dateTo : (newValue.dateTo?.toISOString() ?? '')
|
||||
// Only set the date back to flatpickr when it's an actual date.
|
||||
// Otherwise flatpickr runs in an endless loop and slows down the browser.
|
||||
const dateFrom = parseDateOrString(from.value, false)
|
||||
|
|
@ -208,14 +209,22 @@ const customRangeActive = computed<boolean>(() => {
|
|||
})
|
||||
|
||||
const buttonText = computed<string>(() => {
|
||||
if (from.value !== '' && to.value !== '') {
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
if (from.value === '' || to.value === '') {
|
||||
return t('task.show.select')
|
||||
}
|
||||
|
||||
return t('task.show.select')
|
||||
// Show the preset's name when the range matches one, rather than the raw datemath.
|
||||
const preset = Object.entries(DATE_RANGES).find(
|
||||
([, range]) => from.value === range[0] && to.value === range[1],
|
||||
)
|
||||
if (preset) {
|
||||
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||
}
|
||||
|
||||
return t('input.datepickerRange.fromto', {
|
||||
from: from.value,
|
||||
to: to.value,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@
|
|||
</ProjectSettingsDropdown>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pageTitle"
|
||||
class="project-title-wrapper"
|
||||
>
|
||||
<span class="project-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<TimerBadge />
|
||||
<OpenQuickActions />
|
||||
<Notifications />
|
||||
<Dropdown>
|
||||
|
|
@ -121,13 +129,17 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { PERMISSIONS as Permissions } from '@/constants/permissions'
|
||||
import { PRO_FEATURE } from '@/constants/proFeatures'
|
||||
|
||||
import ProjectSettingsDropdown from '@/components/project/ProjectSettingsDropdown.vue'
|
||||
import Dropdown from '@/components/misc/Dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/DropdownItem.vue'
|
||||
import Notifications from '@/components/notifications/Notifications.vue'
|
||||
import TimerBadge from '@/components/time-tracking/TimerBadge.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import MenuButton from '@/components/home/MenuButton.vue'
|
||||
|
|
@ -151,12 +163,20 @@ const background = computed(() => baseStore.background)
|
|||
const canWriteCurrentProject = computed(() => baseStore.currentProject?.maxPermission !== null && baseStore.currentProject?.maxPermission !== undefined && baseStore.currentProject.maxPermission > Permissions.READ)
|
||||
const menuActive = computed(() => baseStore.menuActive)
|
||||
|
||||
// Standalone pages (no project) surface their route's title in the header.
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const pageTitle = computed(() => {
|
||||
const title = route.meta.title as string | undefined
|
||||
return title ? t(title) : ''
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const imprintUrl = computed(() => configStore.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled('admin_panel'))
|
||||
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { computed } from 'vue'
|
||||
import { useNow } from '@vueuse/core'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useColorScheme } from '@/composables/useColorScheme'
|
||||
|
||||
import LogoFull from '@/assets/logo-full.svg?component'
|
||||
|
|
@ -13,9 +14,10 @@ const now = useNow({
|
|||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const { isDark } = useColorScheme()
|
||||
|
||||
const Logo = computed(() => window.ALLOW_ICON_CHANGES
|
||||
const Logo = computed(() => configStore.allowIconChanges
|
||||
&& authStore.settings.frontendSettings.allowIconChanges
|
||||
&& now.value.getMonth() === 5
|
||||
? LogoFullPride
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@
|
|||
{{ $t('team.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li v-if="timeTrackingEnabled">
|
||||
<RouterLink :to="{ name: 'time-tracking'}">
|
||||
<span class="menu-item-icon icon">
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</span>
|
||||
{{ $t('timeTracking.title') }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</menu>
|
||||
</nav>
|
||||
|
||||
|
|
@ -133,12 +141,17 @@ import Loading from '@/components/misc/Loading.vue'
|
|||
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
|
||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
:disabled="disabled || undefined"
|
||||
@click.stop="toggleDatePopup"
|
||||
>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
<i v-if="date === null && emptyLabel !== ''">{{ emptyLabel }}</i>
|
||||
<template v-else>
|
||||
{{ date === null ? chooseDateLabel : formatDisplayDate(date) }}
|
||||
</template>
|
||||
</SimpleButton>
|
||||
|
||||
<CustomTransition name="fade">
|
||||
|
|
@ -16,6 +19,7 @@
|
|||
>
|
||||
<DatepickerInline
|
||||
v-model="date"
|
||||
:show-shortcuts="showShortcuts"
|
||||
@update:modelValue="updateData"
|
||||
/>
|
||||
|
||||
|
|
@ -48,12 +52,17 @@ const props = withDefaults(defineProps<{
|
|||
modelValue: Date | null | string,
|
||||
chooseDateLabel?: string,
|
||||
disabled?: boolean,
|
||||
showShortcuts?: boolean,
|
||||
// When the value is null, show this (italic) instead of chooseDateLabel.
|
||||
emptyLabel?: string,
|
||||
}>(), {
|
||||
chooseDateLabel: () => {
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
return t('input.datepicker.chooseDate')
|
||||
},
|
||||
disabled: false,
|
||||
showShortcuts: true,
|
||||
emptyLabel: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -1,66 +1,68 @@
|
|||
<template>
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<template v-if="showShortcuts">
|
||||
<BaseButton
|
||||
v-if="(new Date()).getHours() < 21"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('today')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'calendar-alt']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.today') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('today') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('tomorrow')"
|
||||
>
|
||||
<span class="icon"><Icon :icon="['far', 'sun']" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.tomorrow') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('tomorrow') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextMonday')"
|
||||
>
|
||||
<span class="icon"><Icon icon="coffee" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextMonday') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextMonday') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-if="!((new Date()).getDay() === 0 && (new Date()).getHours() >= 21)"
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('thisWeekend')"
|
||||
>
|
||||
<span class="icon"><Icon icon="cocktail" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.thisWeekend') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('thisWeekend') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('laterThisWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="chess-knight" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.laterThisWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('laterThisWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
class="datepicker__quick-select-date"
|
||||
@click.stop="setDate('nextWeek')"
|
||||
>
|
||||
<span class="icon"><Icon icon="forward" /></span>
|
||||
<span class="text">
|
||||
<span>{{ $t('input.datepicker.nextWeek') }}</span>
|
||||
<span class="weekday">{{ getWeekdayFromStringInterval('nextWeek') }}</span>
|
||||
</span>
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="flatpickr-container">
|
||||
<flat-pickr
|
||||
|
|
@ -87,9 +89,12 @@ import {useFlatpickrLanguage} from '@/helpers/useFlatpickrLanguage'
|
|||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Date | null | string
|
||||
}>()
|
||||
showShortcuts?: boolean
|
||||
}>(), {
|
||||
showShortcuts: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [Date | null],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {library} from '@fortawesome/fontawesome-svg-core'
|
||||
import {
|
||||
faAlignLeft,
|
||||
faAngleLeft,
|
||||
faAngleRight,
|
||||
faAnglesUp,
|
||||
faArchive,
|
||||
|
|
@ -121,6 +122,7 @@ library.add(faCode)
|
|||
library.add(faQuoteRight)
|
||||
library.add(faListUl)
|
||||
library.add(faAlignLeft)
|
||||
library.add(faAngleLeft)
|
||||
library.add(faAngleRight)
|
||||
library.add(faArchive)
|
||||
library.add(faArrowLeft)
|
||||
|
|
|
|||
|
|
@ -1,66 +1,96 @@
|
|||
<template>
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
<Teleport :to="teleportTarget">
|
||||
<Notifications
|
||||
position="bottom left"
|
||||
:max="2"
|
||||
:ignore-duplicates="true"
|
||||
class="global-notification"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template #body="{ item, close }">
|
||||
<!-- FIXME: overlay whole notification with button and add event listener on that button instead -->
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="[
|
||||
item.type,
|
||||
]"
|
||||
@click="close()"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
<div
|
||||
v-if="item.title"
|
||||
class="notification-title"
|
||||
>
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="notification-content">
|
||||
<template v-if="Array.isArray(item.text)">
|
||||
<template
|
||||
v-for="(t, k) in item.text"
|
||||
:key="k"
|
||||
>
|
||||
{{ t }}<br>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
<template v-else>
|
||||
{{ item.text }}
|
||||
</template>
|
||||
<span
|
||||
v-if="item.duplicates > 0"
|
||||
class="tw:text-xs tw:font-bold tw:ml-1"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
×{{ item.duplicates + 1 }}
|
||||
</span>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="item.data?.actions?.length > 0"
|
||||
class="mbs-2 tw:flex tw:justify-end tw:gap-2"
|
||||
>
|
||||
<XButton
|
||||
v-for="(action, i) in item.data.actions"
|
||||
:key="'action_' + i"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
variant="secondary"
|
||||
@click="action.callback"
|
||||
>
|
||||
{{ action.title }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Notifications>
|
||||
</template>
|
||||
</Notifications>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue'
|
||||
|
||||
const teleportTarget = ref<string | HTMLElement>('body')
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
function syncTeleportTarget() {
|
||||
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
|
||||
teleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncTeleportTarget()
|
||||
observer = new MutationObserver(syncTeleportTarget)
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['open'],
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-notification {
|
||||
z-index: 9999;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@
|
|||
<template #default>
|
||||
<Card :has-content="false">
|
||||
<div class="gantt-options">
|
||||
<FormField :label="$t('project.gantt.range')">
|
||||
<FormField :label="$t('misc.dateRange')">
|
||||
<Foo
|
||||
id="range"
|
||||
ref="flatPickerEl"
|
||||
v-model="flatPickerDateRange"
|
||||
:config="flatPickerConfig"
|
||||
class="input"
|
||||
:placeholder="$t('project.gantt.range')"
|
||||
:placeholder="$t('misc.dateRange')"
|
||||
/>
|
||||
</FormField>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@
|
|||
@click.stop="showSetLimitInput = true"
|
||||
>
|
||||
{{
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('project.kanban.noLimit')})
|
||||
$t('project.kanban.limit', {limit: bucket.limit > 0 ? bucket.limit : $t('misc.notSet')})
|
||||
}}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<div class="task-time-tracking">
|
||||
<XButton
|
||||
v-if="entries.length > 0"
|
||||
v-tooltip="$t('timeTracking.logTime')"
|
||||
v-cy="'addTaskTimeEntry'"
|
||||
class="is-pulled-right d-print-none"
|
||||
:class="{'is-active': showForm}"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:shadow="false"
|
||||
@click="showForm = !showForm"
|
||||
/>
|
||||
<h3 class="title is-5">
|
||||
{{ $t('timeTracking.title') }}
|
||||
</h3>
|
||||
<TimeEntryForm
|
||||
v-if="formVisible"
|
||||
:task-id="taskId"
|
||||
:entry="editingEntry"
|
||||
:recent-entries="entries"
|
||||
@saved="onSaved"
|
||||
@cancel="editingEntry = null"
|
||||
/>
|
||||
<TimeEntryList
|
||||
class="mbs-4"
|
||||
:entries="entries"
|
||||
:card="false"
|
||||
:empty-text="$t('timeTracking.list.emptyTask')"
|
||||
hide-label-column
|
||||
@edit="editingEntry = $event"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||
|
||||
import {useTimeEntryService} from '@/services/timeEntry'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const entries = ref<ITimeEntry[]>([])
|
||||
const editingEntry = ref<ITimeEntry | null>(null)
|
||||
const showForm = ref(false)
|
||||
|
||||
// Like related tasks: the form is implicit when empty, otherwise behind the +.
|
||||
const formVisible = computed(() => entries.value.length === 0 || showForm.value || editingEntry.value !== null)
|
||||
|
||||
async function load() {
|
||||
const {items} = await useTimeEntryService().getAll({
|
||||
filter: `task_id = ${props.taskId}`,
|
||||
perPage: 250,
|
||||
})
|
||||
entries.value = items
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
editingEntry.value = null
|
||||
showForm.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onDelete(id: number) {
|
||||
await timeTrackingStore.removeEntry(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
watch(() => props.taskId, load, {immediate: true})
|
||||
// The header badge can start/stop the timer without going through this form;
|
||||
// reload so the row reflects the stop (its new end time).
|
||||
watch(() => timeTrackingStore.activeTimer, load)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
<template>
|
||||
<form
|
||||
ref="formEl"
|
||||
v-cy="'timeEntryForm'"
|
||||
class="time-entry-form"
|
||||
@submit.prevent="saveEntry"
|
||||
>
|
||||
<div
|
||||
v-if="taskId === undefined"
|
||||
class="field-columns"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||
<ProjectSearch v-model="selectedProject" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedTask"
|
||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="foundTasks"
|
||||
label="title"
|
||||
@search="findTasks"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.comment.comment') }}</label>
|
||||
<input
|
||||
v-model="comment"
|
||||
v-cy="'timeEntryComment'"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="$t('timeTracking.form.commentPlaceholder')"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field is-grouped from-to-row">
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.from') }}</label>
|
||||
<Datepicker
|
||||
v-model="from"
|
||||
:show-shortcuts="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="control is-expanded">
|
||||
<label class="label">{{ $t('input.datepickerRange.to') }}</label>
|
||||
<Datepicker
|
||||
v-model="to"
|
||||
:show-shortcuts="false"
|
||||
:empty-label="$t('misc.notSet')"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.form.smartFill')"
|
||||
v-cy="'smartFill'"
|
||||
class="smart-fill"
|
||||
:aria-label="$t('timeTracking.form.smartFill')"
|
||||
@click="smartFill"
|
||||
>
|
||||
<Icon :icon="['far', 'clock']" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field form-actions">
|
||||
<template v-if="isEditing">
|
||||
<XButton
|
||||
v-cy="'updateTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.update') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:disabled="isSaving"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</XButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<XButton
|
||||
v-cy="'saveTimeEntry'"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="saveEntry"
|
||||
>
|
||||
{{ $t('timeTracking.form.save') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-cy="'startTimer'"
|
||||
variant="secondary"
|
||||
:disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
@click="startTimer"
|
||||
>
|
||||
{{ $t('timeTracking.form.startTimer') }}
|
||||
</XButton>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive, watch, nextTick} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import Multiselect from '@/components/input/Multiselect.vue'
|
||||
import Datepicker from '@/components/input/Datepicker.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {smartFillStart} from '@/helpers/time/smartFillStart'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
// When set, the entry is locked to this task and the project/task pickers are hidden.
|
||||
taskId?: number
|
||||
// When set, the form edits this entry (Update + Cancel) instead of creating.
|
||||
entry?: ITimeEntry | null
|
||||
// Entries the smart-clock looks at to continue from the last one's end.
|
||||
recentEntries?: ITimeEntry[]
|
||||
}>(), {
|
||||
taskId: undefined,
|
||||
entry: undefined,
|
||||
recentEntries: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const authStore = useAuthStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const isEditing = computed(() => props.entry != null)
|
||||
|
||||
const formEl = ref<HTMLFormElement | null>(null)
|
||||
const selectedProject = ref<IProject | null>(null)
|
||||
const selectedTask = ref<ITask | null>(null)
|
||||
const from = ref<Date | null>(new Date())
|
||||
const to = ref<Date | null>(null)
|
||||
const comment = ref('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Task and project are mutually exclusive (XOR) — selecting one clears the other,
|
||||
// so applyTarget never picks a stale target the user has since changed.
|
||||
watch(selectedTask, task => {
|
||||
if (task !== null) {
|
||||
selectedProject.value = null
|
||||
}
|
||||
})
|
||||
watch(selectedProject, project => {
|
||||
if (project !== null) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const foundTasks = ref<ITask[]>([])
|
||||
async function findTasks(query: string) {
|
||||
if (query === '') {
|
||||
foundTasks.value = []
|
||||
return
|
||||
}
|
||||
const result = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||
foundTasks.value = selectedProject.value === null
|
||||
? result
|
||||
: result.filter(task => task.projectId === selectedProject.value?.id)
|
||||
}
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
// In edit mode the entry already has a valid container; an update that sends
|
||||
// neither keeps it, so don't block submit if the prefill lookup failed.
|
||||
isEditing.value || props.taskId !== undefined || selectedTask.value !== null || selectedProject.value !== null,
|
||||
)
|
||||
|
||||
function smartFill() {
|
||||
from.value = smartFillStart(
|
||||
props.recentEntries,
|
||||
authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||
new Date(),
|
||||
)
|
||||
to.value = new Date()
|
||||
}
|
||||
|
||||
// Whichever of task / project is set lands on the payload (XOR — enforced by canSubmit).
|
||||
function applyTarget(payload: Partial<ITimeEntry>) {
|
||||
if (props.taskId !== undefined) {
|
||||
payload.taskId = props.taskId
|
||||
} else if (selectedTask.value !== null) {
|
||||
payload.taskId = selectedTask.value.id
|
||||
} else if (selectedProject.value !== null) {
|
||||
payload.projectId = selectedProject.value.id
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(includeEnd: boolean): Partial<ITimeEntry> {
|
||||
const payload: Partial<ITimeEntry> = {
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? new Date(),
|
||||
}
|
||||
applyTarget(payload)
|
||||
// Saving a manual entry always has an end (an empty "To" means "until now");
|
||||
// only the Start-timer path omits it to create a running timer.
|
||||
if (includeEnd) {
|
||||
payload.endTime = to.value ?? new Date()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function reset() {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = null
|
||||
comment.value = ''
|
||||
from.value = new Date()
|
||||
to.value = null
|
||||
}
|
||||
|
||||
// Prefill from the entry being edited; a null entry returns the form to create mode.
|
||||
watch(() => props.entry, async entry => {
|
||||
if (entry == null) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
comment.value = entry.comment
|
||||
from.value = entry.startTime
|
||||
to.value = entry.endTime
|
||||
// Bring the form into view — the edit button may be far down the list.
|
||||
await nextTick()
|
||||
formEl.value?.scrollIntoView({behavior: 'smooth', block: 'center'})
|
||||
if (props.taskId !== undefined) {
|
||||
return
|
||||
}
|
||||
if (entry.taskId > 0) {
|
||||
selectedProject.value = null
|
||||
try {
|
||||
selectedTask.value = await taskService.get(new TaskModel({id: entry.taskId})) as ITask
|
||||
} catch {
|
||||
selectedTask.value = null
|
||||
}
|
||||
} else if (entry.projectId > 0) {
|
||||
selectedTask.value = null
|
||||
selectedProject.value = (projectStore.projects[entry.projectId] as IProject) ?? null
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
async function submit(includeEnd: boolean) {
|
||||
if (!canSubmit.value) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload = buildPayload(includeEnd)
|
||||
// A started timer begins now (click time), not when the form first loaded.
|
||||
if (!includeEnd) {
|
||||
payload.startTime = new Date()
|
||||
}
|
||||
await timeTrackingStore.createEntry(payload)
|
||||
reset()
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpdate() {
|
||||
const entry = props.entry
|
||||
if (!canSubmit.value || entry == null) {
|
||||
return
|
||||
}
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: Partial<ITimeEntry> & {id: number} = {
|
||||
id: entry.id,
|
||||
comment: comment.value,
|
||||
startTime: from.value ?? entry.startTime,
|
||||
// A running entry stays running (null); a completed one can't be reopened,
|
||||
// so keep its end if "To" was cleared (the API rejects clearing it).
|
||||
endTime: entry.endTime === null ? to.value : (to.value ?? entry.endTime),
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
}
|
||||
applyTarget(payload)
|
||||
await timeTrackingStore.updateEntry(payload)
|
||||
emit('saved')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveEntry = () => (isEditing.value ? submitUpdate() : submit(true))
|
||||
const startTimer = () => submit(false)
|
||||
function cancelEdit() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.field-columns {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
> .field {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.from-to-row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smart-fill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 2.5em;
|
||||
inline-size: 2.5em;
|
||||
border-radius: $radius;
|
||||
color: var(--primary);
|
||||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
<template>
|
||||
<p
|
||||
v-if="rows.length === 0"
|
||||
class="has-text-centered has-text-grey is-italic"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
<component
|
||||
:is="card ? Card : 'div'"
|
||||
v-else
|
||||
v-bind="card ? {padding: false, hasContent: false} : {}"
|
||||
>
|
||||
<div class="has-horizontal-overflow">
|
||||
<table class="table has-actions is-hoverable is-fullwidth mbe-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('task.attributes.project') }}
|
||||
</th>
|
||||
<th v-if="!hideLabelColumn">
|
||||
{{ $t('timeTracking.form.task') }}
|
||||
</th>
|
||||
<th>{{ $t('task.comment.comment') }}</th>
|
||||
<th class="nowrap">
|
||||
{{ $t('timeTracking.list.time') }}
|
||||
</th>
|
||||
<th class="nowrap has-text-right">
|
||||
{{ $t('timeTracking.list.duration') }}
|
||||
</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in rows"
|
||||
:key="row.entry.id"
|
||||
v-cy="'timeEntry'"
|
||||
>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<template
|
||||
v-for="(project, i) in row.projectChain"
|
||||
:key="project.id"
|
||||
>
|
||||
<RouterLink :to="{ name: 'project.index', params: { projectId: project.id } }">
|
||||
{{ project.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="i < row.projectChain.length - 1"
|
||||
class="has-text-grey"
|
||||
> > </span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="!hideLabelColumn">
|
||||
<RouterLink
|
||||
v-if="row.entry.taskId > 0"
|
||||
:to="{ name: 'task.detail', params: { id: row.entry.taskId } }"
|
||||
>
|
||||
{{ row.taskIdentifier }}{{ row.taskTitle ? ` - ${row.taskTitle}` : '' }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="has-text-grey">
|
||||
{{ row.entry.comment }}
|
||||
</td>
|
||||
<td class="nowrap has-text-grey">
|
||||
{{ timeRange(row.entry) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-semibold">
|
||||
{{ row.seconds === null ? '' : formatDuration(row.seconds) }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right">
|
||||
<template v-if="row.entry.userId === currentUserId">
|
||||
<BaseButton
|
||||
v-tooltip="$t('menu.edit')"
|
||||
v-cy="'editTimeEntry'"
|
||||
class="entry-action"
|
||||
:aria-label="$t('menu.edit')"
|
||||
@click="emit('edit', row.entry)"
|
||||
>
|
||||
<Icon icon="pen" />
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
v-tooltip="$t('misc.delete')"
|
||||
v-cy="'deleteTimeEntry'"
|
||||
class="entry-action entry-delete"
|
||||
:aria-label="$t('misc.delete')"
|
||||
@click="emit('delete', row.entry.id)"
|
||||
>
|
||||
<Icon icon="trash-alt" />
|
||||
</BaseButton>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td
|
||||
:colspan="hideLabelColumn ? 2 : 4"
|
||||
class="has-text-weight-bold"
|
||||
>
|
||||
{{ $t('timeTracking.list.total') }}
|
||||
</td>
|
||||
<td class="nowrap has-text-right has-text-weight-bold">
|
||||
{{ formatDuration(totalSeconds) }}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, watch} from 'vue'
|
||||
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {formatDate} from '@/helpers/time/formatDate'
|
||||
import {useTimeFormat} from '@/composables/useTimeFormat'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
entries: ITimeEntry[]
|
||||
// Drop the project + task columns when every entry belongs to the same task
|
||||
// (e.g. the task-detail page).
|
||||
hideLabelColumn?: boolean
|
||||
// Wrap the table in a Card box; set false to render it inline (no card background).
|
||||
card?: boolean
|
||||
// Override the empty-state message (defaults to the per-day wording).
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
hideLabelColumn: false,
|
||||
card: true,
|
||||
emptyText: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [id: number]
|
||||
edit: [entry: ITimeEntry]
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const {store: timeFormat} = useTimeFormat()
|
||||
|
||||
// Only the author can update/delete (enforced server-side); shared lists include
|
||||
// others' entries, so hide the controls on rows the current user doesn't own.
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = computed(() => authStore.info?.id)
|
||||
|
||||
// Task entries carry only a task id; resolve the full task lazily (for its
|
||||
// title, identifier, and parent project) and cache it.
|
||||
const taskService = new TaskService()
|
||||
const tasks = ref<Record<number, ITask>>({})
|
||||
const inFlight = new Set<number>()
|
||||
async function ensureTask(taskId: number) {
|
||||
if (taskId === 0 || tasks.value[taskId] !== undefined || inFlight.has(taskId)) {
|
||||
return
|
||||
}
|
||||
inFlight.add(taskId)
|
||||
try {
|
||||
tasks.value[taskId] = await taskService.get(new TaskModel({id: taskId}))
|
||||
} catch {
|
||||
// Leave unresolved — the row falls back to #<id>.
|
||||
} finally {
|
||||
inFlight.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.entries, entries => {
|
||||
entries.forEach(entry => ensureTask(entry.taskId))
|
||||
}, {immediate: true})
|
||||
|
||||
function entrySeconds(entry: ITimeEntry): number {
|
||||
const end = entry.endTime ?? new Date()
|
||||
return Math.floor((end.getTime() - entry.startTime.getTime()) / 1000)
|
||||
}
|
||||
|
||||
const rows = computed(() => props.entries.map(entry => {
|
||||
const task = entry.taskId > 0 ? tasks.value[entry.taskId] : undefined
|
||||
const projectId = task?.projectId ?? (entry.projectId > 0 ? entry.projectId : 0)
|
||||
const project = projectId > 0 ? projectStore.projects[projectId] as IProject | undefined : undefined
|
||||
const ancestors = project ? projectStore.getAncestors(project) : []
|
||||
|
||||
return {
|
||||
entry,
|
||||
// Full ancestor chain (root → leaf), each link-able.
|
||||
projectChain: ancestors.map(p => ({id: p.id, title: getProjectTitle(p)})),
|
||||
taskIdentifier: task ? (task.identifier || `#${task.index}`) : (entry.taskId > 0 ? `#${entry.taskId}` : ''),
|
||||
taskTitle: task?.title ?? '',
|
||||
// A running entry (no end) has no settled duration — leave it blank.
|
||||
seconds: entry.endTime !== null ? entrySeconds(entry) : null,
|
||||
}
|
||||
}))
|
||||
|
||||
const totalSeconds = computed(() => rows.value.reduce((sum, row) => sum + (row.seconds ?? 0), 0))
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return formatDate(date, timeFormat.value === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A')
|
||||
}
|
||||
|
||||
function timeRange(entry: ITimeEntry): string {
|
||||
const start = formatTime(entry.startTime)
|
||||
if (entry.endTime === null) {
|
||||
return `${start} – …`
|
||||
}
|
||||
return `${start} – ${formatTime(entry.endTime)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-action {
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
& + & {
|
||||
margin-inline-start: .5rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.entry-delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="timeTrackingStore.hasActiveTimer"
|
||||
v-cy="'timerBadge'"
|
||||
class="timer-badge"
|
||||
>
|
||||
<RouterLink
|
||||
:to="{ name: 'time-tracking' }"
|
||||
class="timer-badge__elapsed"
|
||||
:title="$t('timeTracking.title')"
|
||||
>
|
||||
{{ elapsed }}
|
||||
</RouterLink>
|
||||
<BaseButton
|
||||
v-tooltip="$t('timeTracking.stop')"
|
||||
v-cy="'stopTimer'"
|
||||
class="timer-badge__stop"
|
||||
:aria-label="$t('timeTracking.stop')"
|
||||
@click="stop"
|
||||
>
|
||||
<Icon icon="stop" />
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted, onUnmounted} from 'vue'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const now = ref(new Date())
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const elapsed = computed(() => {
|
||||
const timer = timeTrackingStore.activeTimer
|
||||
if (timer === null) {
|
||||
return ''
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - timer.startTime.getTime()) / 1000))
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mmss = `${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}`
|
||||
return hours >= 1 ? `${hours}:${mmss}` : mmss
|
||||
})
|
||||
|
||||
const isStopping = ref(false)
|
||||
async function stop() {
|
||||
if (isStopping.value) {
|
||||
return
|
||||
}
|
||||
isStopping.value = true
|
||||
try {
|
||||
await timeTrackingStore.stopTimer()
|
||||
} finally {
|
||||
isStopping.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The badge lives in the always-mounted header, so it owns the app-wide timer
|
||||
// sync. Subscribing is harmless when the feature is off (no events are emitted);
|
||||
// only the hydrate hits the gated endpoint, so guard that.
|
||||
timeTrackingStore.subscribeToTimerEvents()
|
||||
if (configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||
timeTrackingStore.hydrateActiveTimer()
|
||||
}
|
||||
interval = setInterval(() => {
|
||||
now.value = new Date()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
timeTrackingStore.unsubscribeFromTimerEvents()
|
||||
if (interval !== undefined) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timer-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timer-badge__elapsed {
|
||||
padding-inline: .75rem .25rem;
|
||||
color: var(--primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-badge__stop {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: .5rem;
|
||||
color: var(--grey-400);
|
||||
transition: color $transition;
|
||||
|
||||
&:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Licensed "pro" features the server may advertise via /info's enabled_pro_features.
|
||||
// Use these instead of bare strings when calling configStore.isProFeatureEnabled.
|
||||
export const PRO_FEATURE = {
|
||||
ADMIN_PANEL: 'admin_panel',
|
||||
TIME_TRACKING: 'time_tracking',
|
||||
} as const
|
||||
|
||||
export type ProFeature = typeof PRO_FEATURE[keyof typeof PRO_FEATURE]
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {smartFillStart} from './smartFillStart'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
function entry(startTime: Date, endTime: Date | null): ITimeEntry {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
taskId: 0,
|
||||
projectId: 0,
|
||||
startTime,
|
||||
endTime,
|
||||
comment: '',
|
||||
created: startTime,
|
||||
updated: startTime,
|
||||
maxPermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('smartFillStart', () => {
|
||||
const now = new Date('2026-06-07T15:30:00')
|
||||
|
||||
it('continues from the latest entry end time', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T11:00:00'), new Date('2026-06-07T12:30:00')),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T12:30:00'))
|
||||
})
|
||||
|
||||
it('ignores still-running entries (no end) when picking the latest end', () => {
|
||||
const entries = [
|
||||
entry(new Date('2026-06-07T09:00:00'), new Date('2026-06-07T10:00:00')),
|
||||
entry(new Date('2026-06-07T13:00:00'), null),
|
||||
]
|
||||
expect(smartFillStart(entries, '09:00', now)).toEqual(new Date('2026-06-07T10:00:00'))
|
||||
})
|
||||
|
||||
it('falls back to the default start time on the current day when there are no entries', () => {
|
||||
expect(smartFillStart([], '08:15', now)).toEqual(new Date('2026-06-07T08:15:00'))
|
||||
})
|
||||
|
||||
it('falls back to 09:00 when no default is configured', () => {
|
||||
expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00'))
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
// The smart-clock start time: continue from the most recent entry's end so
|
||||
// consecutive entries don't overlap or leave gaps; with no completed entry to
|
||||
// continue from, fall back to the user's configured default start (HH:MM) on
|
||||
// the given day.
|
||||
export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date {
|
||||
const lastEnd = recentEntries
|
||||
.map(entry => entry.endTime)
|
||||
.filter((end): end is Date => end !== null)
|
||||
.sort((a, b) => b.getTime() - a.getTime())[0]
|
||||
if (lastEnd !== undefined) {
|
||||
return new Date(lastEnd)
|
||||
}
|
||||
|
||||
const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number)
|
||||
const start = new Date(now)
|
||||
start.setHours(hours || 0, minutes || 0, 0, 0)
|
||||
return start
|
||||
}
|
||||
|
|
@ -172,6 +172,7 @@
|
|||
"yyyy\/mm\/dd": "YYYY\/MM\/DD"
|
||||
},
|
||||
"timeFormat": "Time format",
|
||||
"timeTrackingDefaultStart": "Time tracking smart-fill start time",
|
||||
"timeFormatOptions": {
|
||||
"12h": "12-hour (AM/PM)",
|
||||
"24h": "24-hour (HH:mm)"
|
||||
|
|
@ -470,7 +471,6 @@
|
|||
"month": "Month",
|
||||
"day": "Day",
|
||||
"hour": "Hour",
|
||||
"range": "Date Range",
|
||||
"chartLabel": "Project Gantt Chart",
|
||||
"taskBarsForRow": "Task bars for row {rowId}",
|
||||
"taskBarLabel": "Task: {task}. From {startDate} to {endDate}. {dateType}. Click to edit, drag to move.",
|
||||
|
|
@ -499,7 +499,6 @@
|
|||
"kanban": {
|
||||
"title": "Kanban",
|
||||
"limit": "Limit: {limit}",
|
||||
"noLimit": "Not Set",
|
||||
"doneBucket": "Done bucket",
|
||||
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
|
||||
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
|
||||
|
|
@ -783,7 +782,10 @@
|
|||
"closeDialog": "Close dialog",
|
||||
"closeQuickActions": "Close quick actions",
|
||||
"skipToContent": "Skip to main content",
|
||||
"sortBy": "Sort by"
|
||||
"sortBy": "Sort by",
|
||||
"dateRange": "Date range",
|
||||
"notSet": "Not set",
|
||||
"user": "User"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Project color",
|
||||
|
|
@ -993,6 +995,7 @@
|
|||
"repeatAfter": "Set Repeating Interval",
|
||||
"percentDone": "Set Progress",
|
||||
"attachments": "Add Attachments",
|
||||
"timeTracking": "Track time",
|
||||
"relatedTasks": "Add Relation",
|
||||
"moveProject": "Move",
|
||||
"duplicate": "Duplicate",
|
||||
|
|
@ -1462,6 +1465,32 @@
|
|||
"frontendVersion": "Frontend version: {version}",
|
||||
"apiVersion": "API version: {version}"
|
||||
},
|
||||
"timeTracking": {
|
||||
"title": "Time tracking",
|
||||
"stop": "Stop timer",
|
||||
"logTime": "Log time",
|
||||
"editEntry": "Edit entry",
|
||||
"form": {
|
||||
"task": "Task",
|
||||
"taskSearch": "Search for a task…",
|
||||
"commentPlaceholder": "What did you work on?",
|
||||
"save": "Save entry",
|
||||
"startTimer": "Start timer",
|
||||
"update": "Update entry",
|
||||
"smartFill": "Fill from last entry"
|
||||
},
|
||||
"list": {
|
||||
"emptyTask": "No time tracked for this task yet.",
|
||||
"emptyFiltered": "No time tracked for the selected filters.",
|
||||
"total": "Total",
|
||||
"time": "Time",
|
||||
"duration": "Duration"
|
||||
},
|
||||
"browse": {
|
||||
"selectRange": "Select a range",
|
||||
"userSearch": "Search for a user…"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"units": {
|
||||
"seconds": "second|seconds",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,10 +5,36 @@
|
|||
},
|
||||
"home": {
|
||||
"welcomeNight": "Goedenacht {username}!",
|
||||
"welcomeNightOwl": "Hey {username}, nachtuil",
|
||||
"welcomeNightBurning": "Tot diep in de nacht doorwerken {username}?",
|
||||
"welcomeNightQuiet": "Stille uurtjes, {username}",
|
||||
"welcomeNightLate": "Het is laat, {username}",
|
||||
"welcomeNightMoonlit": "Maanverlichte planning, {username}?",
|
||||
"welcomeMorning": "Goedemorgen {username}!",
|
||||
"welcomeMorningHey": "Hey {username}, klaar om te gaan?",
|
||||
"welcomeMorningFresh": "Verse start, {username}",
|
||||
"welcomeMorningCoffee": "Koffie en taken, {username}?",
|
||||
"welcomeMorningRise": "Opstaan en plannen, {username}",
|
||||
"welcomeMorningBack": "Welkom terug, {username}",
|
||||
"welcomeMondayFresh": "Verse week, {username}",
|
||||
"welcomeTuesday": "Fijne dinsdag, {username}",
|
||||
"welcomeWednesdayMid": "Midden van de week alweer, {username}",
|
||||
"welcomeThursday": "Bijna klaar, {username}",
|
||||
"welcomeFridayPush": "Vrijdag nog even door, {username}?",
|
||||
"welcomeSaturday": "Weekendstand, {username}",
|
||||
"welcomeSundaySession": "Zondagse sessie, {username}?",
|
||||
"welcomeDay": "Hallo {username}!",
|
||||
"welcomeDayBack": "Weer aan de slag, {username}",
|
||||
"welcomeDayFocus": "Nu met focus, {username}",
|
||||
"welcomeDayKeepGoing": "Blijf doorgaan, {username}",
|
||||
"welcomeDayWhatsNext": "Wat komt er nu, {username}?",
|
||||
"welcomeDayGood": "Goedemiddag {username}",
|
||||
"welcomeEvening": "Goedenavond {username}!",
|
||||
"welcomeEveningWind": "Nu tot rust komen, {username}?",
|
||||
"welcomeEveningReturns": "{username} keert terug",
|
||||
"welcomeEveningWrap": "Tijd om af te ronden, {username}?",
|
||||
"welcomeEveningOneMore": "Nog één ding, {username}?",
|
||||
"welcomeEveningStill": "Nog steeds bezig, {username}?",
|
||||
"lastViewed": "Laatst bekeken",
|
||||
"addToHomeScreen": "Voeg deze app toe aan je startscherm voor snellere toegang en verbeterde ervaring.",
|
||||
"goToOverview": "Ga naar overzicht",
|
||||
|
|
@ -54,6 +80,15 @@
|
|||
"authenticating": "Authenticeren…",
|
||||
"openIdStateError": "Status komt niet overeen, weigert door te gaan!",
|
||||
"openIdGeneralError": "Er was een fout tijdens het authenticeren bij de externe applicatie.",
|
||||
"openIdTotpRequired": "Je account vereist tweestapsverificatie. Voer je TOTP-code in en log opnieuw in.",
|
||||
"openIdTotpSubmit": "Doorgaan",
|
||||
"oauthMissingParams": "Ontbrekende OAuth parameters: {params}",
|
||||
"oauthRedirectedToApp": "Je bent doorgestuurd naar de app. Je kunt dit tabblad nu sluiten.",
|
||||
"desktopTryDemo": "Probeer de demo",
|
||||
"desktopCustomServer": "Aangepaste server-URL",
|
||||
"desktopCustomServerDescription": "Voer de URL in van je Vikunja server om te beginnen.",
|
||||
"desktopWaitingForAuth": "Wachten op authenticatie…",
|
||||
"desktopOAuthError": "Authenticatie mislukt: {error}",
|
||||
"logout": "Uitloggen",
|
||||
"emailInvalid": "Vul een geldig e-mailadres in.",
|
||||
"usernameRequired": "Geef een gebruikersnaam op.",
|
||||
|
|
@ -68,9 +103,23 @@
|
|||
"alreadyHaveAnAccount": "Heb je al een account?",
|
||||
"remember": "Ingelogd blijven",
|
||||
"registrationDisabled": "Registratie is uitgeschakeld.",
|
||||
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt."
|
||||
"passwordResetTokenMissing": "Wachtwoord reset token ontbreekt.",
|
||||
"registrationFailed": "Er is een fout opgetreden tijdens de registratie. Controleer je invoer en probeer opnieuw."
|
||||
},
|
||||
"settings": {
|
||||
"bots": {
|
||||
"title": "Bot-gebruikers",
|
||||
"description": "Bot-gebruikers zijn API-only gebruikers waar jij eigenaar van bent. Ze kunnen worden toegevoegd aan projecten, taken krijgen en authenticeren met API-tokens. Ze kunnen niet interactief inloggen.",
|
||||
"namePlaceholder": "Mijn Assistent",
|
||||
"create": "Bot aanmaken",
|
||||
"enable": "Inschakelen",
|
||||
"badge": "Bot",
|
||||
"delete": {
|
||||
"header": "Verwijder deze bot-gebruiker",
|
||||
"text1": "Weet je zeker dat je bot-gebruiker \"{username}\" wilt verwijderen?",
|
||||
"text2": "Dit is onomkeerbaar. Alle API-tokens die bij deze bot horen, worden ingetrokken."
|
||||
}
|
||||
},
|
||||
"title": "Instellingen",
|
||||
"newPasswordTitle": "Je wachtwoord bijwerken",
|
||||
"newPassword": "Nieuw wachtwoord",
|
||||
|
|
@ -96,12 +145,21 @@
|
|||
"weekStart": "Week begint op",
|
||||
"weekStartSunday": "Zondag",
|
||||
"weekStartMonday": "Maandag",
|
||||
"weekStartTuesday": "Dinsdag",
|
||||
"weekStartWednesday": "Woensdag",
|
||||
"weekStartThursday": "Donderdag",
|
||||
"weekStartFriday": "Vrijdag",
|
||||
"weekStartSaturday": "Zaterdag",
|
||||
"language": "Taal",
|
||||
"defaultProject": "Standaardproject",
|
||||
"defaultView": "Standaardweergave",
|
||||
"timezone": "Tijdzone",
|
||||
"overdueTasksRemindersTime": "Tijdstip herinneringsmail voor achterstallige taken",
|
||||
"quickAddDefaultReminders": "Standaardherinneringen voor Snel Toevoegen",
|
||||
"quickAddDefaultRemindersDescription": "Deze herinneringen worden automatisch toegevoegd aan elke taak die is gemaakt via Magisch Snel Toevoegen met een vervaldatum.",
|
||||
"quickAddDefaultRemindersHint": "Voeg herinnering(en) toe ten opzichte van de vervaldatum van de taak. Laat leeg om uit te schakelen.",
|
||||
"filterUsedOnOverview": "Opgeslagen filter toegepast op de overzichtspagina",
|
||||
"showLastViewed": "Toon laatst bekeken projecten op de overzichtspagina",
|
||||
"minimumPriority": "Minimale zichtbare taakprioriteit",
|
||||
"dateDisplay": "Datumweergave",
|
||||
"dateDisplayOptions": {
|
||||
|
|
@ -125,7 +183,13 @@
|
|||
"taskAndNotifications": "Projecten & taken",
|
||||
"privacy": "Privacy",
|
||||
"localization": "Lokalisatie",
|
||||
"appearance": "Uiterlijk & gedrag"
|
||||
"appearance": "Uiterlijk & gedrag",
|
||||
"desktop": "Desktop app"
|
||||
},
|
||||
"desktop": {
|
||||
"quickEntryShortcut": "Sneltoets voor snelle invoer",
|
||||
"shortcutRecorderPlaceholder": "Klik om sneltoets in te stellen",
|
||||
"shortcutRecorderRecording": "Druk op een toetscombinatie…"
|
||||
},
|
||||
"totp": {
|
||||
"title": "Tweestapsverificatie",
|
||||
|
|
@ -135,15 +199,32 @@
|
|||
"scanQR": "Als alternatief kan je ook deze QR code scannen:",
|
||||
"passcode": "Je toegangscode",
|
||||
"passcodePlaceholder": "Een code gegenereerd door je TOTP-app",
|
||||
"confirmNotice": "Na het inschakelen van tweestapsverificatie, wordt je uitgelogd uit alle sessies en moet je opnieuw inloggen.",
|
||||
"setupSuccess": "Je hebt tweestapsverificatie succesvol ingesteld!",
|
||||
"enterPassword": "Voer alsjeblieft je wachtwoord in",
|
||||
"disable": "Tweestapsverificatie uitschakelen",
|
||||
"confirmSuccess": "Je hebt tweestapsverificatie succesvol ingeschakeld!",
|
||||
"disableSuccess": "Uitschakelen tweestapsverificatie is geslaagd."
|
||||
},
|
||||
"caldav": {
|
||||
"title": "CalDAV",
|
||||
"howTo": "Je kunt Vikunja verbinden met CalDAV-clients om taken te bekijken en beheren vanuit verschillende clients. Voer deze url in bij je client:",
|
||||
"more": "Meer informatie over CalDAV in Vikunja",
|
||||
"tokens": "CalDAV tokens"
|
||||
"tokens": "CalDAV tokens",
|
||||
"tokensHowTo": "Voor CalDAV-authenticatie gebruik je jouw normale accountwachtwoord of een speciaal CalDAV-token.",
|
||||
"createToken": "Maak een CalDAV-token",
|
||||
"tokenCreated": "Hier is je nieuwe token: {token}",
|
||||
"wontSeeItAgain": "Schrijf het op of bewaar het veilig - je kunt het hierna niet meer inzien.",
|
||||
"mustUseToken": "Je moet een CalDAV-token aanmaken om CalDAV te gebruiken met een externe client. Gebruik het token in het wachtwoordveld van uw client.",
|
||||
"usernameIs": "Je gebruikersnaam voor CalDAV is: {0}",
|
||||
"apiTokenHint": "Je kunt ook een API-token gebruiken met CalDAV-permissie. Maak er een aan in {link}."
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Atom Feed",
|
||||
"howTo": "Je kunt je abonneren op je Vikunja meldingen met elke Atom-compatibele feedlezer. Gebruik deze URL:",
|
||||
"usernameIs": "Je gebruikersnaam voor de feed is: {0}",
|
||||
"apiTokenHint": "Authenticeer met een API-token die {scope} permissies heeft. Maak er een aan in {link}.",
|
||||
"tokenTitle": "Atom feed"
|
||||
},
|
||||
"avatar": {
|
||||
"title": "Avatar",
|
||||
|
|
@ -174,6 +255,10 @@
|
|||
"backgroundBrightness": {
|
||||
"title": "Achtergrond helderheid"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook notificaties",
|
||||
"description": "Configureer webhook-URL's om POST aanvragen te ontvangen wanneer herinneringen of achterstallige gebeurtenissen worden geactiveerd. Deze webhooks ontvangen gebeurtenissen van al je projecten."
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "API tokens",
|
||||
"general": "Met API tokens kun je Vikunja's API gebruiken zonder gebruikersnaam en wachtwoord.",
|
||||
|
|
@ -189,6 +274,13 @@
|
|||
"expired": "Dit token is verlopen {ago}.",
|
||||
"tokenCreatedSuccess": "Hier is je API-token: {token}",
|
||||
"tokenCreatedNotSeeAgain": "Bewaar het op een veilige locatie, het wordt slechts één keer getoond!",
|
||||
"presets": {
|
||||
"title": "Snelle voorkeursinstellingen",
|
||||
"readOnly": "Alleen-lezen",
|
||||
"tasks": "Taakbeheer",
|
||||
"projects": "Projectmanagement",
|
||||
"fullAccess": "Volledige toegang"
|
||||
},
|
||||
"delete": {
|
||||
"header": "Dit token verwijderen",
|
||||
"text1": "Weet je zeker dat je token \"{token}\" wilt verwijderen?",
|
||||
|
|
@ -200,6 +292,20 @@
|
|||
"expiresAt": "Verloopt op",
|
||||
"permissions": "Machtigingen"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessies",
|
||||
"description": "Dit zijn alle apparaten die momenteel zijn ingelogd op je account. Je kunt elke sessie intrekken om dat apparaat uit te loggen. Het kan tot 10 minuten duren voordat de intrekking volledig van kracht is.",
|
||||
"deviceInfo": "Apparaat",
|
||||
"ipAddress": "IP-adres",
|
||||
"lastActive": "Laatst actief",
|
||||
"current": "Huidige sessie",
|
||||
"delete": {
|
||||
"header": "Sessie intrekken",
|
||||
"text": "Weet je zeker dat je deze sessie wilt intrekken? Het apparaat wordt uitgelogd. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen."
|
||||
},
|
||||
"deleteSuccess": "De sessie is ingetrokken. Het kan tot 10 minuten duren voordat de sessie volledig is verlopen.",
|
||||
"noOtherSessions": "Geen andere actieve sessies."
|
||||
}
|
||||
},
|
||||
"deletion": {
|
||||
|
|
@ -354,7 +460,8 @@
|
|||
"addPlaceholder": "Taak toevoegen…",
|
||||
"empty": "Dit project is momenteel leeg.",
|
||||
"newTaskCta": "Taak aanmaken.",
|
||||
"editTask": "Taak bewerken"
|
||||
"editTask": "Taak bewerken",
|
||||
"sort": "Sorteren"
|
||||
},
|
||||
"gantt": {
|
||||
"title": "Gantt",
|
||||
|
|
@ -380,7 +487,10 @@
|
|||
"taskAriaLabel": "Taak: {task}",
|
||||
"taskAriaLabelById": "Taak {id}",
|
||||
"partialDatesStart": "Alleen startdatum (open-einde)",
|
||||
"partialDatesEnd": "Alleen einddatum (open-einde)"
|
||||
"partialDatesEnd": "Alleen einddatum (open-einde)",
|
||||
"expandGroup": "Groep uitklappen: {task}",
|
||||
"collapseGroup": "Groep inklappen: {task}",
|
||||
"toggleRelationArrows": "Relatiepijlen wisselen"
|
||||
},
|
||||
"table": {
|
||||
"title": "Tabel",
|
||||
|
|
@ -410,7 +520,8 @@
|
|||
"bucketTitleSavedSuccess": "De categorietitel is succesvol opgeslagen.",
|
||||
"bucketLimitSavedSuccess": "De categorielimiet is succesvol opgeslagen.",
|
||||
"collapse": "Deze categorie inklappen",
|
||||
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen."
|
||||
"bucketLimitReached": "U heeft de categorielimiet bereikt. Verwijder taken of verhoog de limiet om nieuwe taken toe te voegen.",
|
||||
"bucketOptions": "Categorie-opties"
|
||||
},
|
||||
"pseudo": {
|
||||
"favorites": {
|
||||
|
|
@ -533,6 +644,29 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"manually": "Handmatig",
|
||||
"apply": "Sortering toepassen",
|
||||
"description": "Kies hoe taken in deze lijst worden gesorteerd. Bij handmatig sorteren kun je taken verslepen om ze anders te ordenen.",
|
||||
"options": {
|
||||
"titleAsc": "Titel (A–Z)",
|
||||
"titleDesc": "Titel (Z–A)",
|
||||
"priorityDesc": "Prioriteit (hoogste eerst)",
|
||||
"priorityAsc": "Prioriteit (laagste eerst)",
|
||||
"dueDateAsc": "Vervaldatum (vroegste eerst)",
|
||||
"dueDateDesc": "Vervaldatum (laatste eerst)",
|
||||
"startDateAsc": "Startdatum (vroegste eerst)",
|
||||
"startDateDesc": "Startdatum (laatste eerst)",
|
||||
"endDateAsc": "Einddatum (vroegste eerst)",
|
||||
"endDateDesc": "Einddatum (laatste eerst)",
|
||||
"percentDoneDesc": "% gereed (meest gereed eerst)",
|
||||
"percentDoneAsc": "% gereed (minst gereed eerst)",
|
||||
"createdDesc": "Aangemaakt (nieuwste eerst)",
|
||||
"createdAsc": "Aangemaakt (oudste eerst)",
|
||||
"updatedDesc": "Bijgewerkt (nieuwste eerst)",
|
||||
"updatedAsc": "Bijgewerkt (oudste eerst)"
|
||||
}
|
||||
},
|
||||
"migrate": {
|
||||
"title": "Importeer vanuit een andere dienst",
|
||||
"titleService": "Importeer je gegevens van {name} naar Vikunja",
|
||||
|
|
@ -547,7 +681,30 @@
|
|||
"importUpload": "Om gegevens van {name} te importeren in Vikunja, klik je op de knop hieronder om een bestand te kiezen.",
|
||||
"upload": "Bestand uploaden",
|
||||
"migrationStartedWillReciveEmail": "Vikunja gaat nu je lijsten/projecten, taken, notities, herinneringen en bestanden van {service} importeren. Omdat dit een tijdje zal duren, sturen we je een e-mail zodra het klaar is. Je kunt dit venster nu sluiten.",
|
||||
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is."
|
||||
"migrationInProgress": "Er is momenteel een migratie aan de gang. Wacht tot dit voltooid is.",
|
||||
"csv": {
|
||||
"description": "Importeer taken uit een CSV-bestand met aangepaste kolomtoewijzing.",
|
||||
"uploadDescription": "Kies een CSV-bestand om te importeren. Het bestand moet taakgegevens bevatten met kolomkoppen in de eerste rij.",
|
||||
"selectFile": "Kies CSV-bestand",
|
||||
"columnMappingDescription": "Wijs elke kolom in je CSV-bestand toe aan een taakattribuut. Vikunja heeft de meest waarschijnlijke toewijzingen automatisch gedetecteerd. Het voorbeeld hieronder wordt automatisch bijgewerkt wanneer je de instellingen aanpast.",
|
||||
"parsingOptions": "Opties voor verwerking",
|
||||
"delimiter": "Scheidingsteken",
|
||||
"dateFormat": "Datumnotatie",
|
||||
"skipRows": "Rijen overslaan",
|
||||
"mapColumns": "Kolommen toewijzen",
|
||||
"example": "bijv.",
|
||||
"preview": "Voorbeeld",
|
||||
"previewDescription": "Toont de eerste 5 van {count} taken die zullen worden geïmporteerd.",
|
||||
"import": "Taken importeren",
|
||||
"untitled": "Naamloze taak",
|
||||
"ignore": "Negeren",
|
||||
"delimiters": {
|
||||
"comma": "Komma (,)",
|
||||
"semicolon": "Puntkomma (;)",
|
||||
"tab": "Tab",
|
||||
"pipe": "Pijp (|)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"title": "Labels",
|
||||
|
|
@ -587,7 +744,9 @@
|
|||
"upcoming": "Aankomend",
|
||||
"settings": "Instellingen",
|
||||
"imprint": "Imprint",
|
||||
"privacy": "Privacybeleid"
|
||||
"privacy": "Privacybeleid",
|
||||
"closeSidebar": "Zijbalk sluiten",
|
||||
"home": "Vikunja home"
|
||||
},
|
||||
"misc": {
|
||||
"loading": "Bezig met laden…",
|
||||
|
|
@ -619,9 +778,15 @@
|
|||
"createdBy": "Aangemaakt door {0}",
|
||||
"actions": "Acties",
|
||||
"cannotBeUndone": "Dit kan niet ongedaan gemaakt worden!",
|
||||
"avatarOfUser": "{user}'s profielfoto"
|
||||
"avatarOfUser": "{user}'s profielfoto",
|
||||
"closeBanner": "Banner sluiten",
|
||||
"closeDialog": "Dialoogvenster sluiten",
|
||||
"closeQuickActions": "Snelle acties sluiten",
|
||||
"skipToContent": "Direct naar hoofdinhoud",
|
||||
"sortBy": "Sorteren op"
|
||||
},
|
||||
"input": {
|
||||
"projectColor": "Projectkleur",
|
||||
"resetColor": "Kleur resetten",
|
||||
"datepicker": {
|
||||
"today": "Vandaag",
|
||||
|
|
@ -681,6 +846,9 @@
|
|||
"toggleHeaderCell": "Celkopteksten in-/uitschakelen",
|
||||
"mergeOrSplit": "Samenvoegen of splitsen",
|
||||
"fixTables": "Tabellen repareren"
|
||||
},
|
||||
"emoji": {
|
||||
"empty": "Geen emoji gevonden"
|
||||
}
|
||||
},
|
||||
"multiselect": {
|
||||
|
|
@ -694,6 +862,7 @@
|
|||
"date": "Datum",
|
||||
"ranges": {
|
||||
"today": "Vandaag",
|
||||
"tomorrow": "Morgen",
|
||||
"thisWeek": "Deze week",
|
||||
"restOfThisWeek": "De rest van deze week",
|
||||
"nextWeek": "Volgende week",
|
||||
|
|
@ -767,6 +936,7 @@
|
|||
"addReminder": "Herinnering toevoegen…",
|
||||
"doneSuccess": "De taak is succesvol aangemerkt als voltooid.",
|
||||
"undoneSuccess": "Het voltooien van de taak is succesvol teruggedraaid.",
|
||||
"readOnlyCheckbox": "Je hebt alleen leestoegang tot deze taak en kunt deze niet als voltooid markeren.",
|
||||
"movedToProject": "De taak werd verplaatst naar {project}.",
|
||||
"undo": "Ongedaan maken",
|
||||
"checklistTotal": "{checked} van {total} taken",
|
||||
|
|
@ -779,7 +949,8 @@
|
|||
"select": "Selecteer datumbereik",
|
||||
"noTasks": "Niets te doen - fijne dag!",
|
||||
"filterByLabel": "Gefilterd op label {label}",
|
||||
"clearLabelFilter": "Wis labelfilter"
|
||||
"clearLabelFilter": "Wis labelfilter",
|
||||
"savedFilterIgnored": "Je opgeslagen startpagina-filter wordt niet toegepast als je taken bekijkt per label."
|
||||
},
|
||||
"detail": {
|
||||
"chooseDueDate": "Klik hier om een vervaldatum in te stellen",
|
||||
|
|
@ -793,9 +964,15 @@
|
|||
"doneAt": "{0} voltooid",
|
||||
"updateSuccess": "De taak is succesvol opgeslagen.",
|
||||
"deleteSuccess": "De taak is succesvol verwijderd.",
|
||||
"duplicateSuccess": "De taak is succesvol gedupliceerd.",
|
||||
"noBucket": "Geen categorie",
|
||||
"bucketChangedSuccess": "De taakcategorie is succesvol gewijzigd.",
|
||||
"belongsToProject": "Deze taak hoort bij project '{project}'",
|
||||
"back": "Terug naar project",
|
||||
"due": "Vervalt {at}",
|
||||
"closeTaskDetail": "Sluit taakdetails",
|
||||
"title": "Taakdetails",
|
||||
"markAsDone": "Markeer '{task}' als gereed",
|
||||
"scrollToBottom": "Scroll naar beneden",
|
||||
"organization": "Organisatie",
|
||||
"management": "Beheer",
|
||||
|
|
@ -818,6 +995,7 @@
|
|||
"attachments": "Bijlagen toevoegen",
|
||||
"relatedTasks": "Relatie toevoegen",
|
||||
"moveProject": "Verplaatsen",
|
||||
"duplicate": "Dupliceer",
|
||||
"color": "Kleur instellen",
|
||||
"delete": "Verwijder",
|
||||
"favorite": "Toevoegen aan favorieten",
|
||||
|
|
@ -840,6 +1018,8 @@
|
|||
"relatedTasks": "Verwante Taken",
|
||||
"reminders": "Herinneringen",
|
||||
"repeat": "Herhalen",
|
||||
"comment": "{count} reactie | {count} reacties",
|
||||
"commentCount": "Aantal reacties",
|
||||
"startDate": "Begindatum",
|
||||
"title": "Titel",
|
||||
"updated": "Bijgewerkt",
|
||||
|
|
@ -875,6 +1055,7 @@
|
|||
},
|
||||
"comment": {
|
||||
"title": "Reacties",
|
||||
"loading": "Reacties laden…",
|
||||
"edited": "bewerkt op {date}",
|
||||
"creating": "Opmerking maken…",
|
||||
"placeholder": "Voeg je reactie toe, druk op '/' voor meer opties…",
|
||||
|
|
@ -885,7 +1066,10 @@
|
|||
"addedSuccess": "De reactie is succesvol toegevoegd.",
|
||||
"permalink": "Kopieer permalink naar deze reactie",
|
||||
"sortNewestFirst": "Nieuwste eerst",
|
||||
"sortOldestFirst": "Oudste eerst"
|
||||
"sortOldestFirst": "Oudste eerst",
|
||||
"reply": "Beantwoorden",
|
||||
"jumpToOriginal": "Ga naar originele reactie",
|
||||
"deletedComment": "verwijderde reactie"
|
||||
},
|
||||
"mention": {
|
||||
"noUsersFound": "Geen gebruikers gevonden"
|
||||
|
|
@ -977,6 +1161,7 @@
|
|||
},
|
||||
"quickAddMagic": {
|
||||
"hint": "Gebruik magische prefixes om vervaldata, toegewezen personen en andere taakeigenschappen te definiëren.",
|
||||
"quickEntryHint": "Gebruik magische voorvoegsels voor datums, labels en meer. Open de Vikunja hoofd-app en bekijk de tooltip op de taakinvoer voor meer details.",
|
||||
"title": "Snel-toevoegen magie",
|
||||
"intro": "Bij het aanmaken van een taak kun je speciale trefwoorden gebruiken om direct kenmerken toe te voegen aan de nieuwe taak. Hiermee kun je veelgebruikte kenmerken veel sneller toevoegen aan taken.",
|
||||
"multiple": "Je kan dit meerdere keren gebruiken.",
|
||||
|
|
@ -1149,9 +1334,11 @@
|
|||
"none": "Je hebt geen meldingen. Fijne dag!",
|
||||
"explainer": "Hier verschijnen meldingen wanneer acties, projecten of taken gebeuren waarop u bent geabonneerd.",
|
||||
"markAllRead": "Markeer alle meldingen als gelezen",
|
||||
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd."
|
||||
"markAllReadSuccess": "Alle meldingen zijn als gelezen gemarkeerd.",
|
||||
"subscribeFeed": "Abonneren op meldingen via Atom feed"
|
||||
},
|
||||
"quickActions": {
|
||||
"notLoggedIn": "Log eerst in op het Vikunja hoofdscherm.",
|
||||
"commands": "Opdrachten",
|
||||
"placeholder": "Typ een opdracht of zoek…",
|
||||
"hint": "Je kunt {project} gebruiken om het zoeken te beperken tot een project. Combineer {project} of {label} (labels) met een zoekopdracht om te zoeken naar een taak met deze labels of op dat project. Gebruik {assignee} om alleen te zoeken naar teams.",
|
||||
|
|
@ -1284,5 +1471,66 @@
|
|||
"weeks": "week|weken",
|
||||
"years": "jaar|jaren"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Beheer",
|
||||
"labels": {
|
||||
"users": "Gebruikers",
|
||||
"tasks": "Taken"
|
||||
},
|
||||
"overview": {
|
||||
"shares": "Deelbare koppelingen",
|
||||
"linkSharesShort": "link",
|
||||
"teamSharesShort": "team",
|
||||
"userSharesShort": "gebruiker",
|
||||
"version": "Versie",
|
||||
"license": "Licentie",
|
||||
"licenseValidUntil": "Geldig tot",
|
||||
"licenseExpiresIn": "over {days} dagen",
|
||||
"licenseLastVerified": "Laatst geverifieerd",
|
||||
"licenseNever": "nooit",
|
||||
"licenseLastCheckFailed": "laatste controle mislukt",
|
||||
"licenseFeatures": "Functionaliteiten",
|
||||
"licenseInstance": "Instance ID",
|
||||
"licenseManage": "Beheren"
|
||||
},
|
||||
"searchUsersPlaceholder": "Zoek op gebruikersnaam of e-mail…",
|
||||
"users": {
|
||||
"status": "Status",
|
||||
"details": "Details",
|
||||
"detailsTitle": "Gebruiker: {username}",
|
||||
"issuer": "Uitgever",
|
||||
"issuerLocal": "Lokaal",
|
||||
"issuerUrl": "Uitgever URL",
|
||||
"subject": "Onderwerp",
|
||||
"statusActive": "Actief",
|
||||
"statusEmailConfirmation": "E-mailbevestiging vereist",
|
||||
"statusDisabled": "Uitgeschakeld",
|
||||
"statusLocked": "Account vergrendeld",
|
||||
"isAdminLabel": "Beheerder",
|
||||
"addUser": "Gebruiker toevoegen",
|
||||
"createTitle": "Gebruiker aanmaken",
|
||||
"nameLabel": "Naam",
|
||||
"skipEmailConfirm": "E-mailbevestiging overslaan",
|
||||
"createSubmit": "Gebruiker aanmaken",
|
||||
"saveButton": "Wijzigingen opslaan",
|
||||
"createdSuccess": "Gebruiker {username} aangemaakt.",
|
||||
"updatedSuccess": "Gebruiker {username} bijgewerkt.",
|
||||
"deletedSuccess": "Gebruiker {username} verwijderd.",
|
||||
"deleteScheduledSuccess": "Gebruiker {username} ontvangt een bevestigingsmail om de verwijdering te plannen.",
|
||||
"confirmDeleteTitle": "Gebruiker verwijderen?",
|
||||
"confirmDeleteIntro": "Hoe moet gebruiker {username} worden verwijderd?",
|
||||
"deleteModeScheduled": "Verwijdering plannen",
|
||||
"deleteModeScheduledHelp": "Bij 'verwijdering plannen' ontvangt de gebruiker een bevestigingsmail, lijkend op een zelfgestarte accountverwijdering.",
|
||||
"deleteModeNow": "Nu verwijderen",
|
||||
"deleteModeNowHelp": "'Nu verwijderen' verwijdert de gebruiker en hun data onmiddellijk. Dit kan niet ongedaan worden gemaakt."
|
||||
},
|
||||
"projects": {
|
||||
"ownerLabel": "Eigenaar",
|
||||
"reassignOwner": "Nieuwe eigenaar toewijzen",
|
||||
"reassignTitle": "Opnieuw toewijzen {title}",
|
||||
"reassignedSuccess": "Projecteigenaar opnieuw toegewezen.",
|
||||
"newOwnerLabel": "Nieuwe eigenaar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1059,7 +1059,7 @@
|
|||
"edited": "змінено: {date}",
|
||||
"creating": "Створюю коментар…",
|
||||
"placeholder": "Введіть коментар, натисніть '/' для додаткових опцій…",
|
||||
"comment": "Залишити",
|
||||
"comment": "Зберегти коментар",
|
||||
"delete": "Видалити коментар",
|
||||
"deleteText1": "Справді впровадити?",
|
||||
"deleteSuccess": "Коментар успішно видалено.",
|
||||
|
|
@ -1148,11 +1148,10 @@
|
|||
"repeat": {
|
||||
"everyDay": "Щодня",
|
||||
"everyWeek": "Щотижня",
|
||||
"every30d": "Щомісяця",
|
||||
"mode": "Спосіб",
|
||||
"monthly": "Щомісяця",
|
||||
"fromCurrentDate": "Щодень закінчення",
|
||||
"each": "Що",
|
||||
"fromCurrentDate": "З дня закінчення",
|
||||
"each": "Кожен",
|
||||
"specifyAmount": "Вкажіть величину…",
|
||||
"hours": "Години",
|
||||
"days": "День",
|
||||
|
|
@ -1218,8 +1217,8 @@
|
|||
"success": "Вживача успішно видалено зі спільноти."
|
||||
},
|
||||
"leave": {
|
||||
"title": "Покинути спільноту",
|
||||
"text1": "Справді покинути?",
|
||||
"title": "Залишити спільноту",
|
||||
"text1": "Ви впевнені, що хочете залишити цю спільноту?",
|
||||
"text2": "Ви втратите доступ до всіх проєктів, до яких має доступ ця команда. Якщо передумаєте, вам знадобиться адміністратор команди, щоб додати вас знову.",
|
||||
"success": "Ви покинули спільноту."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ declare global {
|
|||
API_URL: string;
|
||||
SENTRY_ENABLED?: boolean;
|
||||
SENTRY_DSN?: string;
|
||||
ALLOW_ICON_CHANGES: boolean;
|
||||
CUSTOM_LOGO_URL?: string;
|
||||
CUSTOM_LOGO_URL_DARK?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export interface ITask extends IAbstract {
|
|||
reactions: IReactionPerEntity
|
||||
comments: ITaskComment[]
|
||||
commentCount?: number
|
||||
timeEntriesCount?: number
|
||||
|
||||
createdBy: IUser
|
||||
created: Date
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import type {IAbstract} from './IAbstract'
|
||||
|
||||
export interface ITimeEntry extends IAbstract {
|
||||
id: number
|
||||
userId: number
|
||||
// Exactly one of taskId / projectId is set (0 means unset).
|
||||
taskId: number
|
||||
projectId: number
|
||||
startTime: Date
|
||||
// null while the live timer is running.
|
||||
endTime: Date | null
|
||||
comment: string
|
||||
|
||||
created: Date
|
||||
updated: Date
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ export interface IFrontendSettings {
|
|||
commentSortOrder: 'asc' | 'desc'
|
||||
desktopQuickEntryShortcut: string
|
||||
quickAddDefaultReminders: ITaskReminder[]
|
||||
timeTrackingDefaultStart?: string
|
||||
}
|
||||
|
||||
export interface IExtraSettingsLink {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {parseDateOrString} from '@/helpers/time/parseDateOrString'
|
|||
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
|
||||
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
|
||||
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
|
@ -433,6 +434,15 @@ const router = createRouter({
|
|||
name: 'about',
|
||||
component: () => import('@/views/About.vue'),
|
||||
},
|
||||
{
|
||||
path: '/time-tracking',
|
||||
name: 'time-tracking',
|
||||
component: () => import('@/views/time-tracking/TimeTracking.vue'),
|
||||
meta: {
|
||||
requiresTimeTracking: true,
|
||||
title: 'timeTracking.title',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/views/admin/AdminShell.vue'),
|
||||
|
|
@ -519,7 +529,7 @@ router.beforeEach(async (to, from) => {
|
|||
const baseStore = useBaseStore()
|
||||
await baseStore.appReady
|
||||
const configStore = useConfigStore()
|
||||
const featureOn = configStore.isProFeatureEnabled('admin_panel')
|
||||
const featureOn = configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL)
|
||||
// isAdmin comes from /user, not the JWT; force-fetch in case checkAuth() was debounced.
|
||||
if (authStore.info?.isAdmin === undefined) {
|
||||
await authStore.refreshUserInfo()
|
||||
|
|
@ -530,6 +540,15 @@ router.beforeEach(async (to, from) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (to.meta?.requiresTimeTracking) {
|
||||
const baseStore = useBaseStore()
|
||||
await baseStore.appReady
|
||||
const configStore = useConfigStore()
|
||||
if (!configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING)) {
|
||||
return {name: 'not-found'}
|
||||
}
|
||||
}
|
||||
|
||||
if(from.hash && from.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
|
||||
to.hash = from.hash
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {parseTimeEntry} from './timeEntry'
|
||||
|
||||
describe('parseTimeEntry', () => {
|
||||
it('maps snake_case keys and coerces dates', () => {
|
||||
const e = parseTimeEntry({
|
||||
id: 1,
|
||||
user_id: 2,
|
||||
task_id: 3,
|
||||
project_id: 0,
|
||||
start_time: '2020-01-01T09:00:00Z',
|
||||
end_time: '2020-01-01T10:00:00Z',
|
||||
comment: 'work',
|
||||
})
|
||||
expect(e.userId).toBe(2)
|
||||
expect(e.taskId).toBe(3)
|
||||
expect(e.comment).toBe('work')
|
||||
expect(e.startTime).toBeInstanceOf(Date)
|
||||
expect(e.endTime).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('treats a null end time as a running timer', () => {
|
||||
const e = parseTimeEntry({
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
task_id: 1,
|
||||
start_time: '2020-01-01T09:00:00Z',
|
||||
end_time: null,
|
||||
})
|
||||
expect(e.endTime).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher'
|
||||
import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
// Time tracking is the first frontend feature on /api/v2, while the shared
|
||||
// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2
|
||||
// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded
|
||||
// into the proper service layer once the frontend moves fully onto v2.
|
||||
function v2Url(path: string): string {
|
||||
const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/')
|
||||
return new URL(v2Base + path, window.location.origin).toString()
|
||||
}
|
||||
|
||||
export function parseTimeEntry(raw: Record<string, unknown>): ITimeEntry {
|
||||
const e = objectToCamelCase(raw)
|
||||
const end = e.endTime as string | null | undefined
|
||||
return {
|
||||
id: e.id,
|
||||
userId: e.userId,
|
||||
taskId: e.taskId ?? 0,
|
||||
projectId: e.projectId ?? 0,
|
||||
startTime: new Date(e.startTime),
|
||||
// null end_time = a running timer.
|
||||
endTime: end ? new Date(end) : null,
|
||||
comment: e.comment ?? '',
|
||||
created: new Date(e.created),
|
||||
updated: new Date(e.updated),
|
||||
maxPermission: e.maxPermission ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TimeEntryListParams {
|
||||
filter?: string
|
||||
filterTimezone?: string
|
||||
q?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
export interface TimeEntryListResult {
|
||||
items: ITimeEntry[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export function useTimeEntryService() {
|
||||
const http = AuthenticatedHTTPFactory()
|
||||
|
||||
async function getAll(params: TimeEntryListParams = {}): Promise<TimeEntryListResult> {
|
||||
const {data} = await http.get(v2Url('time-entries'), {
|
||||
params: {
|
||||
filter: params.filter,
|
||||
filter_timezone: params.filterTimezone,
|
||||
q: params.q,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
},
|
||||
})
|
||||
return {
|
||||
items: (data.items ?? []).map(parseTimeEntry),
|
||||
total: data.total,
|
||||
page: data.page,
|
||||
perPage: data.per_page,
|
||||
totalPages: data.total_pages,
|
||||
}
|
||||
}
|
||||
|
||||
async function create(entry: Partial<ITimeEntry>): Promise<ITimeEntry> {
|
||||
const {data} = await http.post(v2Url('time-entries'), objectToSnakeCase(entry))
|
||||
return parseTimeEntry(data)
|
||||
}
|
||||
|
||||
async function update(entry: Partial<ITimeEntry> & {id: number}): Promise<ITimeEntry> {
|
||||
const {data} = await http.put(v2Url(`time-entries/${entry.id}`), objectToSnakeCase(entry))
|
||||
return parseTimeEntry(data)
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await http.delete(v2Url(`time-entries/${id}`))
|
||||
}
|
||||
|
||||
async function stopTimer(): Promise<ITimeEntry> {
|
||||
const {data} = await http.post(v2Url('time-entries/timer/stop'))
|
||||
return parseTimeEntry(data)
|
||||
}
|
||||
|
||||
return {getAll, create, update, remove, stopTimer}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import {objectToCamelCase} from '@/helpers/case'
|
|||
|
||||
import type {IProvider} from '@/types/IProvider'
|
||||
import type {MIGRATORS} from '@/views/migrate/migrators'
|
||||
import type {ProFeature} from '@/constants/proFeatures'
|
||||
import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
|
||||
|
||||
export interface ConfigState {
|
||||
|
|
@ -44,6 +45,7 @@ export interface ConfigState {
|
|||
},
|
||||
},
|
||||
publicTeamsEnabled: boolean,
|
||||
allowIconChanges: boolean,
|
||||
enabledProFeatures: string[],
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +86,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
},
|
||||
},
|
||||
publicTeamsEnabled: false,
|
||||
allowIconChanges: true,
|
||||
enabledProFeatures: [],
|
||||
})
|
||||
|
||||
|
|
@ -102,7 +105,7 @@ export const useConfigStore = defineStore('config', () => {
|
|||
Object.assign(state, config)
|
||||
}
|
||||
|
||||
function isProFeatureEnabled(name: string): boolean {
|
||||
function isProFeatureEnabled(name: ProFeature): boolean {
|
||||
return state.enabledProFeatures?.includes(name) ?? false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import {describe, it, expect, beforeEach, vi} from 'vitest'
|
||||
import {setActivePinia, createPinia} from 'pinia'
|
||||
|
||||
import {useTimeTrackingStore} from './timeTracking'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const {getAllMock, removeMock, authInfo} = vi.hoisted(() => ({
|
||||
getAllMock: vi.fn(),
|
||||
removeMock: vi.fn(),
|
||||
authInfo: {value: {id: 7} as {id: number} | null},
|
||||
}))
|
||||
|
||||
vi.mock('@/services/timeEntry', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/services/timeEntry')>()
|
||||
return {
|
||||
...actual,
|
||||
useTimeEntryService: () => ({
|
||||
getAll: getAllMock,
|
||||
remove: removeMock,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({
|
||||
info: authInfo.value,
|
||||
}),
|
||||
}))
|
||||
|
||||
function entry(id: number, endTime: Date | null): ITimeEntry {
|
||||
return {
|
||||
id,
|
||||
userId: 1,
|
||||
taskId: 1,
|
||||
projectId: 0,
|
||||
startTime: new Date(),
|
||||
endTime,
|
||||
comment: '',
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
maxPermission: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('timeTracking store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
getAllMock.mockReset()
|
||||
removeMock.mockReset()
|
||||
authInfo.value = {id: 7}
|
||||
})
|
||||
|
||||
it('a running entry becomes the active timer', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
expect(store.activeTimer?.id).toBe(4)
|
||||
expect(store.hasActiveTimer).toBe(true)
|
||||
})
|
||||
|
||||
it('a stopped entry clears the matching active timer', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
store.applyTimerEvent(entry(4, new Date()))
|
||||
expect(store.activeTimer).toBeNull()
|
||||
})
|
||||
|
||||
it('a stop for a different timer leaves the active one alone', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
store.applyTimerEvent(entry(5, new Date()))
|
||||
expect(store.activeTimer?.id).toBe(4)
|
||||
})
|
||||
|
||||
it('patches a stopped entry in the loaded list', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.browsedEntries = [entry(4, null), entry(5, null)]
|
||||
const stopped = entry(4, new Date('2026-01-01T10:00:00Z'))
|
||||
store.applyTimerEvent(stopped)
|
||||
expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 4)?.endTime).toEqual(stopped.endTime)
|
||||
expect(store.browsedEntries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not insert an unknown entry into the loaded list', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.browsedEntries = [entry(4, null)]
|
||||
store.applyTimerEvent(entry(9, new Date()))
|
||||
expect(store.browsedEntries).toHaveLength(1)
|
||||
expect(store.browsedEntries.find((e: ITimeEntry) => e.id === 9)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hydrates the active timer scoped to the current user', async () => {
|
||||
getAllMock.mockResolvedValue({items: [entry(4, null)]})
|
||||
|
||||
const store = useTimeTrackingStore()
|
||||
await store.hydrateActiveTimer()
|
||||
|
||||
expect(getAllMock).toHaveBeenCalledWith({
|
||||
filter: 'user_id = 7 && end_time = null',
|
||||
perPage: 1,
|
||||
})
|
||||
expect(store.activeTimer?.id).toBe(4)
|
||||
})
|
||||
|
||||
it('clears the active timer when deleting the running entry', async () => {
|
||||
removeMock.mockResolvedValue(undefined)
|
||||
|
||||
const store = useTimeTrackingStore()
|
||||
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
|
||||
await store.removeEntry(4)
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith(4)
|
||||
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5])
|
||||
expect(store.activeTimer).toBeNull()
|
||||
})
|
||||
|
||||
it('applyTimerDeletion drops the entry and clears the matching active timer', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
|
||||
store.applyTimerDeletion(4)
|
||||
|
||||
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([5])
|
||||
expect(store.activeTimer).toBeNull()
|
||||
})
|
||||
|
||||
it('applyTimerDeletion of another entry leaves the active timer alone', () => {
|
||||
const store = useTimeTrackingStore()
|
||||
store.browsedEntries = [entry(4, null), entry(5, new Date())]
|
||||
store.applyTimerEvent(entry(4, null))
|
||||
|
||||
store.applyTimerDeletion(5)
|
||||
|
||||
expect(store.browsedEntries.map((e: ITimeEntry) => e.id)).toEqual([4])
|
||||
expect(store.activeTimer?.id).toBe(4)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import {ref, computed} from 'vue'
|
||||
import {acceptHMRUpdate, defineStore} from 'pinia'
|
||||
|
||||
import {useWebSocket} from '@/composables/useWebSocket'
|
||||
import {useTimeEntryService, parseTimeEntry} from '@/services/timeEntry'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
export const useTimeTrackingStore = defineStore('timeTracking', () => {
|
||||
const activeTimer = ref<ITimeEntry | null>(null)
|
||||
const browsedEntries = ref<ITimeEntry[]>([])
|
||||
|
||||
const hasActiveTimer = computed(() => activeTimer.value !== null)
|
||||
|
||||
async function browseEntries(filter: string) {
|
||||
const {items} = await useTimeEntryService().getAll({
|
||||
filter,
|
||||
filterTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
perPage: 250,
|
||||
})
|
||||
browsedEntries.value = items
|
||||
}
|
||||
|
||||
// Drop a deleted entry from the list and clear the active timer if it was it.
|
||||
// Shared by the local delete and the cross-tab WebSocket "timer.deleted".
|
||||
function applyTimerDeletion(id: number) {
|
||||
browsedEntries.value = browsedEntries.value.filter(entry => entry.id !== id)
|
||||
if (activeTimer.value?.id === id) {
|
||||
activeTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEntry(id: number) {
|
||||
await useTimeEntryService().remove(id)
|
||||
applyTimerDeletion(id)
|
||||
}
|
||||
|
||||
// Replace an already-loaded entry in place so a stop (or any update) is
|
||||
// reflected without a refetch. Never inserts — an event for an entry that
|
||||
// isn't in the current filter shouldn't appear in the list.
|
||||
function patchInList(entry: ITimeEntry) {
|
||||
const index = browsedEntries.value.findIndex(existing => existing.id === entry.id)
|
||||
if (index !== -1) {
|
||||
browsedEntries.value.splice(index, 1, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the active timer from a timer event (WebSocket) or a local
|
||||
// action: an entry with an end time is a stop — clear it if it's the one we
|
||||
// track; otherwise it is the running timer.
|
||||
function applyTimerEvent(entry: ITimeEntry) {
|
||||
patchInList(entry)
|
||||
if (entry.endTime !== null) {
|
||||
if (activeTimer.value?.id === entry.id) {
|
||||
activeTimer.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
activeTimer.value = entry
|
||||
}
|
||||
|
||||
// Source of truth on (re)connect: the caller's own running timer, if any.
|
||||
async function hydrateActiveTimer() {
|
||||
const userId = useAuthStore().info?.id
|
||||
if (userId === undefined) {
|
||||
activeTimer.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const {items} = await useTimeEntryService().getAll({
|
||||
filter: `user_id = ${userId} && end_time = null`,
|
||||
perPage: 1,
|
||||
})
|
||||
activeTimer.value = items[0] ?? null
|
||||
}
|
||||
|
||||
// Create any entry (manual, with an end time, or a running timer when end is
|
||||
// omitted) and reconcile the active timer from the result.
|
||||
async function createEntry(payload: Partial<ITimeEntry>) {
|
||||
const entry = await useTimeEntryService().create(payload)
|
||||
applyTimerEvent(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function updateEntry(payload: Partial<ITimeEntry> & {id: number}) {
|
||||
const entry = await useTimeEntryService().update(payload)
|
||||
applyTimerEvent(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
async function stopTimer() {
|
||||
const entry = await useTimeEntryService().stopTimer()
|
||||
applyTimerEvent(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
let unsubscribers: Array<() => void> = []
|
||||
function subscribeToTimerEvents() {
|
||||
const {subscribe} = useWebSocket()
|
||||
// Ignore messages without a payload (e.g. subscribe acknowledgements).
|
||||
const onEvent = (msg: {data?: unknown}) => {
|
||||
if (msg.data == null) {
|
||||
return
|
||||
}
|
||||
applyTimerEvent(parseTimeEntry(msg.data as Record<string, unknown>))
|
||||
}
|
||||
const onDelete = (msg: {data?: unknown}) => {
|
||||
if (msg.data == null) {
|
||||
return
|
||||
}
|
||||
applyTimerDeletion(parseTimeEntry(msg.data as Record<string, unknown>).id)
|
||||
}
|
||||
unsubscribers.push(subscribe('timer.created', onEvent))
|
||||
unsubscribers.push(subscribe('timer.updated', onEvent))
|
||||
unsubscribers.push(subscribe('timer.deleted', onDelete))
|
||||
}
|
||||
function unsubscribeFromTimerEvents() {
|
||||
unsubscribers.forEach(unsubscribe => unsubscribe())
|
||||
unsubscribers = []
|
||||
}
|
||||
|
||||
return {
|
||||
activeTimer,
|
||||
browsedEntries,
|
||||
hasActiveTimer,
|
||||
applyTimerEvent,
|
||||
applyTimerDeletion,
|
||||
hydrateActiveTimer,
|
||||
browseEntries,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
stopTimer,
|
||||
removeEntry,
|
||||
subscribeToTimerEvents,
|
||||
unsubscribeFromTimerEvents,
|
||||
}
|
||||
})
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useTimeTrackingStore, import.meta.hot))
|
||||
}
|
||||
|
|
@ -366,6 +366,15 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time Tracking -->
|
||||
<div
|
||||
v-if="timeTrackingEnabled && activeFields.timeTracking"
|
||||
:ref="e => setFieldRef('timeTracking', e)"
|
||||
class="content time-tracking"
|
||||
>
|
||||
<TaskTimeTracking :task-id="task.id" />
|
||||
</div>
|
||||
|
||||
<!-- Related Tasks -->
|
||||
<div
|
||||
v-if="activeFields.relatedTasks"
|
||||
|
|
@ -537,6 +546,16 @@
|
|||
|
||||
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
||||
|
||||
<XButton
|
||||
v-if="timeTrackingEnabled"
|
||||
v-cy="'taskTrackTimeAction'"
|
||||
variant="secondary"
|
||||
:icon="['far', 'clock']"
|
||||
@click="setFieldActive('timeTracking')"
|
||||
>
|
||||
{{ $t('task.detail.actions.timeTracking') }}
|
||||
</XButton>
|
||||
|
||||
<XButton
|
||||
v-shortcut="'KeyD'"
|
||||
variant="secondary"
|
||||
|
|
@ -643,11 +662,13 @@ import type {IProject} from '@/modelTypes/IProject'
|
|||
|
||||
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
||||
import {PERMISSIONS} from '@/constants/permissions'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
// partials
|
||||
import Attachments from '@/components/tasks/partials/Attachments.vue'
|
||||
import TaskTimeTracking from '@/components/time-tracking/TaskTimeTracking.vue'
|
||||
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
|
||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||
import Comments from '@/components/tasks/partials/Comments.vue'
|
||||
|
|
@ -682,6 +703,7 @@ import {useKanbanStore} from '@/stores/kanban'
|
|||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
||||
|
|
@ -704,6 +726,8 @@ const {t} = useI18n({useScope: 'global'})
|
|||
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
const configStore = useConfigStore()
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
const kanbanStore = useKanbanStore()
|
||||
const authStore = useAuthStore()
|
||||
const baseStore = useBaseStore()
|
||||
|
|
@ -923,7 +947,12 @@ watch(
|
|||
}
|
||||
|
||||
try {
|
||||
const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']})
|
||||
const expand = ['reactions', 'comments', 'is_unread', 'buckets']
|
||||
if (timeTrackingEnabled.value) {
|
||||
// Only request the (server-computed) count when the feature is on.
|
||||
expand.push('time_entries_count')
|
||||
}
|
||||
const loaded = await taskService.get({id}, {expand})
|
||||
Object.assign(task.value, loaded)
|
||||
taskColor.value = task.value.hexColor
|
||||
setActiveFields()
|
||||
|
|
@ -967,6 +996,7 @@ type FieldType =
|
|||
| 'reminders'
|
||||
| 'repeatAfter'
|
||||
| 'startDate'
|
||||
| 'timeTracking'
|
||||
|
||||
const activeFields: { [type in FieldType]: boolean } = reactive({
|
||||
assignees: false,
|
||||
|
|
@ -982,6 +1012,7 @@ const activeFields: { [type in FieldType]: boolean } = reactive({
|
|||
reminders: false,
|
||||
repeatAfter: false,
|
||||
startDate: false,
|
||||
timeTracking: false,
|
||||
})
|
||||
|
||||
function setActiveFields() {
|
||||
|
|
@ -992,6 +1023,7 @@ function setActiveFields() {
|
|||
// Set all active fields based on values in the model
|
||||
activeFields.assignees = task.value.assignees.length > 0
|
||||
activeFields.attachments = task.value.attachments.length > 0
|
||||
activeFields.timeTracking = (task.value.timeEntriesCount ?? 0) > 0
|
||||
activeFields.dueDate = task.value.dueDate !== null
|
||||
activeFields.endDate = task.value.endDate !== null
|
||||
activeFields.labels = task.value.labels.length > 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
<template>
|
||||
<div class="time-tracking">
|
||||
<div class="time-tracking__actions">
|
||||
<span class="time-tracking__range">
|
||||
{{ rangeLabel }}
|
||||
</span>
|
||||
<div class="time-tracking__buttons">
|
||||
<XButton
|
||||
v-cy="'addTimeEntry'"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:class="{'is-active': showForm}"
|
||||
@click="showForm = !showForm"
|
||||
>
|
||||
{{ $t('timeTracking.logTime') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-cy="'openTimeTrackingFilters'"
|
||||
variant="secondary"
|
||||
icon="filter"
|
||||
:class="{'has-filters': hasFilters}"
|
||||
@click="filterModalOpen = true"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
</XButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
v-if="formVisible"
|
||||
:title="$t(editingEntry ? 'timeTracking.editEntry' : 'timeTracking.logTime')"
|
||||
>
|
||||
<TimeEntryForm
|
||||
:entry="editingEntry"
|
||||
:recent-entries="timeTrackingStore.browsedEntries"
|
||||
@saved="onSaved"
|
||||
@cancel="editingEntry = null"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TimeEntryList
|
||||
:entries="timeTrackingStore.browsedEntries"
|
||||
:empty-text="$t('timeTracking.list.emptyFiltered')"
|
||||
@edit="editingEntry = $event"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
:enabled="filterModalOpen"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="filterModalOpen = false"
|
||||
>
|
||||
<Card
|
||||
class="has-overflow"
|
||||
:title="$t('filters.title')"
|
||||
show-close
|
||||
@close="filterModalOpen = false"
|
||||
>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.dateRange') }}</label>
|
||||
<DatepickerWithRange v-model="dateRange">
|
||||
<template #trigger="{toggle, buttonText}">
|
||||
<XButton
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
@click.prevent.stop="toggle()"
|
||||
>
|
||||
{{ buttonText || $t('timeTracking.browse.selectRange') }}
|
||||
</XButton>
|
||||
</template>
|
||||
</DatepickerWithRange>
|
||||
</div>
|
||||
<div class="filter-columns">
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('task.attributes.project') }}</label>
|
||||
<ProjectSearch v-model="selectedProject" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('timeTracking.form.task') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedTask"
|
||||
:placeholder="$t('timeTracking.form.taskSearch')"
|
||||
:loading="taskService.loading"
|
||||
:search-results="foundTasks"
|
||||
label="title"
|
||||
@search="findTasks"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.title }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.user') }}</label>
|
||||
<Multiselect
|
||||
v-model="selectedUser"
|
||||
:placeholder="$t('timeTracking.browse.userSearch')"
|
||||
:loading="userService.loading"
|
||||
:search-results="foundUsers"
|
||||
label="username"
|
||||
@search="findUsers"
|
||||
>
|
||||
<template #searchResult="{option}">
|
||||
{{ option.username }}
|
||||
</template>
|
||||
</Multiselect>
|
||||
</div>
|
||||
</Card>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, shallowReactive, watch, nextTick, onMounted} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import Modal from '@/components/misc/Modal.vue'
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import DatepickerWithRange from '@/components/date/DatepickerWithRange.vue'
|
||||
import {DATE_RANGES} from '@/components/date/dateRanges'
|
||||
import Multiselect from '@/components/input/Multiselect.vue'
|
||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||
import TimeEntryForm from '@/components/time-tracking/TimeEntryForm.vue'
|
||||
import TimeEntryList from '@/components/time-tracking/TimeEntryList.vue'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import TaskModel from '@/models/task'
|
||||
import UserService from '@/services/user'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useTimeTrackingStore} from '@/stores/timeTracking'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import type {ITask} from '@/modelTypes/ITask'
|
||||
import type {IUser} from '@/modelTypes/IUser'
|
||||
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
|
||||
|
||||
const {t} = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const timeTrackingStore = useTimeTrackingStore()
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
useTitle(() => t('timeTracking.title'))
|
||||
|
||||
const showForm = ref(false)
|
||||
const editingEntry = ref<ITimeEntry | null>(null)
|
||||
const formVisible = computed(() => showForm.value || editingEntry.value !== null)
|
||||
|
||||
function onSaved() {
|
||||
editingEntry.value = null
|
||||
showForm.value = false
|
||||
timeTrackingStore.browseEntries(filter.value)
|
||||
}
|
||||
|
||||
function onDelete(id: number) {
|
||||
timeTrackingStore.removeEntry(id)
|
||||
}
|
||||
|
||||
// --- Filter ---------------------------------------------------------------
|
||||
|
||||
// DatepickerWithRange emits null for a side when the range is cleared (Custom).
|
||||
const dateRange = ref<{dateFrom: Date | string | null, dateTo: Date | string | null}>({
|
||||
dateFrom: 'now/d',
|
||||
dateTo: 'now/d+1d',
|
||||
})
|
||||
const selectedProject = ref<IProject | null>(null)
|
||||
const selectedTask = ref<ITask | null>(null)
|
||||
const selectedUser = ref<IUser | null>(null)
|
||||
const filterModalOpen = ref(false)
|
||||
|
||||
const hasFilters = computed(() =>
|
||||
selectedProject.value !== null ||
|
||||
selectedTask.value !== null ||
|
||||
selectedUser.value !== null ||
|
||||
dateRange.value.dateFrom !== 'now/d' ||
|
||||
dateRange.value.dateTo !== 'now/d+1d',
|
||||
)
|
||||
|
||||
// The active range as a label (the preset name when it matches, else the dates).
|
||||
const rangeLabel = computed(() => {
|
||||
const {dateFrom, dateTo} = dateRange.value
|
||||
if (!dateFrom || !dateTo) {
|
||||
return t('timeTracking.browse.selectRange')
|
||||
}
|
||||
const preset = Object.entries(DATE_RANGES).find(
|
||||
([, range]) => dateFrom === range[0] && dateTo === range[1],
|
||||
)
|
||||
if (preset) {
|
||||
return t(`input.datepickerRange.ranges.${preset[0]}`)
|
||||
}
|
||||
return t('input.datepickerRange.fromto', {from: dateValue(dateFrom), to: dateValue(dateTo)})
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const foundTasks = ref<ITask[]>([])
|
||||
async function findTasks(query: string) {
|
||||
if (query === '') {
|
||||
foundTasks.value = []
|
||||
return
|
||||
}
|
||||
foundTasks.value = await taskService.getAll({}, {s: query, sort_by: 'done'}) as ITask[]
|
||||
}
|
||||
|
||||
const userService = shallowReactive(new UserService())
|
||||
const foundUsers = ref<IUser[]>([])
|
||||
async function findUsers(query: string) {
|
||||
if (query === '') {
|
||||
foundUsers.value = []
|
||||
return
|
||||
}
|
||||
foundUsers.value = await userService.getAll({}, {s: query}) as IUser[]
|
||||
}
|
||||
|
||||
// Datemath preset strings (now/M) pass through unchanged; a custom Date becomes
|
||||
// YYYY-MM-DD — both avoid the ':' the filter grammar tokenises on.
|
||||
function dateValue(value: Date | string): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const filter = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (dateRange.value.dateFrom) {
|
||||
parts.push(`start_time > ${dateValue(dateRange.value.dateFrom)}`)
|
||||
}
|
||||
if (dateRange.value.dateTo) {
|
||||
parts.push(`start_time < ${dateValue(dateRange.value.dateTo)}`)
|
||||
}
|
||||
if (selectedUser.value !== null) {
|
||||
parts.push(`user_id = ${selectedUser.value.id}`)
|
||||
}
|
||||
if (selectedTask.value !== null) {
|
||||
parts.push(`task_id = ${selectedTask.value.id}`)
|
||||
}
|
||||
if (selectedProject.value !== null) {
|
||||
parts.push(`project_id = ${selectedProject.value.id}`)
|
||||
}
|
||||
return parts.join(' && ')
|
||||
})
|
||||
|
||||
// Persist the active filter to the URL so it's shareable and survives reloads.
|
||||
const filterQuery = computed(() => {
|
||||
const q: Record<string, string> = {}
|
||||
if (dateRange.value.dateFrom && dateRange.value.dateFrom !== 'now/d') {
|
||||
q.from = dateValue(dateRange.value.dateFrom)
|
||||
}
|
||||
if (dateRange.value.dateTo && dateRange.value.dateTo !== 'now/d+1d') {
|
||||
q.to = dateValue(dateRange.value.dateTo)
|
||||
}
|
||||
if (selectedProject.value !== null) {
|
||||
q.project = String(selectedProject.value.id)
|
||||
}
|
||||
if (selectedTask.value !== null) {
|
||||
q.task = String(selectedTask.value.id)
|
||||
}
|
||||
if (selectedUser.value !== null) {
|
||||
q.user = selectedUser.value.username
|
||||
}
|
||||
return q
|
||||
})
|
||||
|
||||
const ready = ref(false)
|
||||
|
||||
async function restoreFromQuery() {
|
||||
const q = route.query
|
||||
if (typeof q.from === 'string') {
|
||||
dateRange.value.dateFrom = q.from
|
||||
}
|
||||
if (typeof q.to === 'string') {
|
||||
dateRange.value.dateTo = q.to
|
||||
}
|
||||
// Resolve project/task by id and the user by username up front (the project
|
||||
// store may not be hydrated yet on a hard reload), so the first request
|
||||
// already carries the full filter — and the modal shows the real names.
|
||||
await Promise.all([
|
||||
typeof q.project === 'string'
|
||||
? projectStore.loadProject(Number(q.project))
|
||||
.then(p => { selectedProject.value = p as IProject })
|
||||
.catch(() => { /* project gone — drop the filter */ })
|
||||
: Promise.resolve(),
|
||||
typeof q.task === 'string'
|
||||
? taskService.get(new TaskModel({id: Number(q.task)}))
|
||||
.then(t => { selectedTask.value = t as ITask })
|
||||
.catch(() => { /* task gone — drop the filter */ })
|
||||
: Promise.resolve(),
|
||||
typeof q.user === 'string'
|
||||
? userService.getAll({}, {s: q.user})
|
||||
.then(users => {
|
||||
selectedUser.value = (users as IUser[]).find(u => u.username === q.user) ?? null
|
||||
})
|
||||
.catch(() => { /* user not found — drop the filter */ })
|
||||
: Promise.resolve(),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Standalone page: drop any stale project so the app header shows this
|
||||
// page's title instead of the last visited project.
|
||||
baseStore.handleSetCurrentProject({project: null})
|
||||
await restoreFromQuery()
|
||||
ready.value = true
|
||||
// One request with the fully-restored filter — no flicker through partial filters.
|
||||
timeTrackingStore.browseEntries(filter.value)
|
||||
})
|
||||
|
||||
// DatepickerWithRange only syncs its display from modelValue on change, and it
|
||||
// remounts each time the modal opens — re-push the value so the range shows.
|
||||
watch(filterModalOpen, open => {
|
||||
if (open) {
|
||||
nextTick(() => {
|
||||
dateRange.value = {...dateRange.value}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(filterQuery, q => {
|
||||
if (!ready.value) {
|
||||
return
|
||||
}
|
||||
router.replace({query: q}).catch(() => { /* ignore redundant navigation */ })
|
||||
})
|
||||
|
||||
watch(filter, value => {
|
||||
if (!ready.value) {
|
||||
return
|
||||
}
|
||||
timeTrackingStore.browseEntries(value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.time-tracking__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.time-tracking__range {
|
||||
color: var(--grey-500);
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.time-tracking__buttons {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.filter-columns {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
> .field {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The multiselect's per-row "click or press enter to select" hint is
|
||||
// transparent but still reserves its (long) width, clipping the project/task
|
||||
// title to a few characters in the narrow side-by-side columns. Drop it.
|
||||
:deep(.hint-text) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
$filter-bubble-size: .75rem;
|
||||
|
||||
// Blue dot on the filter button when any filter is active (mirrors project views).
|
||||
.has-filters {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block-start: math.div($filter-bubble-size, -2);
|
||||
inset-inline-end: math.div($filter-bubble-size, -2);
|
||||
|
||||
inline-size: $filter-bubble-size;
|
||||
block-size: $filter-bubble-size;
|
||||
border-radius: 100%;
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -90,25 +90,11 @@ function findProvider(providerKey: string): IProvider | undefined {
|
|||
}
|
||||
|
||||
async function authenticateWithCode() {
|
||||
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
|
||||
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
|
||||
// but instead the "content-auth" component is used. Because this component is just a route and thus
|
||||
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
|
||||
// this re-mounts the component, even if the user is already authenticated.
|
||||
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
|
||||
// which ensures only one auth request is done at a time. We don't simply check if the user is already
|
||||
// authenticated to not prevent the whole authentication if some user is already logged in.
|
||||
if (localStorage.getItem('authenticating')) {
|
||||
return
|
||||
}
|
||||
localStorage.setItem('authenticating', 'true')
|
||||
|
||||
errorMessage.value = ''
|
||||
|
||||
const providerKey = route.params.provider as string
|
||||
|
||||
if (typeof route.query.error !== 'undefined') {
|
||||
localStorage.removeItem('authenticating')
|
||||
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||
errorMessage.value = typeof route.query.message !== 'undefined'
|
||||
? route.query.message as string
|
||||
|
|
@ -118,7 +104,6 @@ async function authenticateWithCode() {
|
|||
|
||||
const state = localStorage.getItem('state')
|
||||
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
|
||||
localStorage.removeItem('authenticating')
|
||||
sessionStorage.removeItem(pendingTotpKey(providerKey))
|
||||
errorMessage.value = t('user.auth.openIdStateError')
|
||||
return
|
||||
|
|
@ -145,8 +130,6 @@ async function authenticateWithCode() {
|
|||
return
|
||||
}
|
||||
errorMessage.value = getErrorText(e)
|
||||
} finally {
|
||||
localStorage.removeItem('authenticating')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -151,6 +151,16 @@
|
|||
:options="timeFormatOptions"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-if="timeTrackingEnabled"
|
||||
:label="$t('user.settings.general.timeTrackingDefaultStart')"
|
||||
layout="two-col"
|
||||
>
|
||||
<FormInput
|
||||
v-model="settings.frontendSettings.timeTrackingDefaultStart"
|
||||
type="time"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
|
@ -306,12 +316,14 @@ import {useTitle} from '@/composables/useTitle'
|
|||
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import type {IUserSettings} from '@/modelTypes/IUserSettings'
|
||||
import {isSavedFilter} from '@/services/savedFilter'
|
||||
import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView'
|
||||
import {PRIORITIES} from '@/constants/priorities'
|
||||
import {DATE_DISPLAY} from '@/constants/dateDisplay'
|
||||
import {TIME_FORMAT} from '@/constants/timeFormat'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import {RELATION_KINDS} from '@/types/IRelationKind'
|
||||
import {isDesktopApp} from '@/helpers/desktopAuth'
|
||||
import ShortcutRecorder from '@/components/misc/ShortcutRecorder.vue'
|
||||
|
|
@ -396,6 +408,8 @@ const languageOptions = computed(() =>
|
|||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const configStore = useConfigStore()
|
||||
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
|
||||
|
||||
const settings = ref<IUserSettings>({
|
||||
...authStore.settings,
|
||||
|
|
@ -416,6 +430,7 @@ const settings = ref<IUserSettings>({
|
|||
defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related',
|
||||
// Clone to escape the store's readonly array type.
|
||||
quickAddDefaultReminders: [...(authStore.settings.frontendSettings.quickAddDefaultReminders ?? [])],
|
||||
timeTrackingDefaultStart: authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
import {test, expect} from '../../support/fixtures'
|
||||
import type {Page, Locator} from '@playwright/test'
|
||||
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {TimeEntryFactory} from '../../factories/time_entry'
|
||||
import {LicenseFactory} from '../../factories/license'
|
||||
import {UserFactory} from '../../factories/user'
|
||||
import {UserProjectFactory} from '../../factories/users_project'
|
||||
|
||||
// Pick a project in the form's project picker. Waits for the project store to
|
||||
// hydrate (the sidebar shows it) before searching so the result is there.
|
||||
async function selectProject(page: Page, form: Locator, title: string) {
|
||||
await expect(page.locator('.menu-container').getByText(title)).toBeVisible()
|
||||
const input = form.locator('.multiselect').first().locator('input')
|
||||
await input.click()
|
||||
// pressSequentially (not fill) so the multiselect's @keyup search fires.
|
||||
await input.pressSequentially(title, {delay: 30})
|
||||
await form.locator('.search-result-button').filter({hasText: title}).first().click()
|
||||
}
|
||||
|
||||
// Pick a task in the form's task picker (the second multiselect, after project).
|
||||
async function selectTask(form: Locator, title: string) {
|
||||
const input = form.locator('.multiselect').nth(1).locator('input')
|
||||
await input.click()
|
||||
await input.pressSequentially(title, {delay: 30})
|
||||
await form.locator('.search-result-button').filter({hasText: title}).first().click()
|
||||
}
|
||||
|
||||
// Open the time-tracking section on a task detail page.
|
||||
async function openTaskTimeTracking(page: Page, taskId: number): Promise<Locator> {
|
||||
await page.goto(`/tasks/${taskId}`)
|
||||
await page.locator('[data-cy="taskTrackTimeAction"]').click()
|
||||
const section = page.locator('.task-time-tracking')
|
||||
await expect(section).toBeVisible()
|
||||
return section
|
||||
}
|
||||
|
||||
test.describe('Time tracking', () => {
|
||||
test.describe('with the feature licensed', () => {
|
||||
test.beforeEach(async () => {
|
||||
await LicenseFactory.enable(['time_tracking'])
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await LicenseFactory.disable()
|
||||
})
|
||||
|
||||
test('shows the page and the sidebar entry', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toBeVisible()
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('logs a manual time entry', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E tracked project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
await selectProject(page, form, 'E2E tracked project')
|
||||
// Smart-fill populates both from and to, so the entry is complete.
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'E2E tracked project'})).toBeVisible()
|
||||
})
|
||||
|
||||
test('saving with an empty To logs a completed entry, not a running timer', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E save project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await selectProject(page, form, 'E2E save project')
|
||||
// No smart-fill: leave "To" empty, then Save.
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
// The entry is completed (no open-ended "…") and no timer started.
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('switching from a task to a project logs against the project', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'XOR project'}, false)
|
||||
await TaskFactory.create(1, {id: 1, title: 'XOR task', project_id: 1}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
|
||||
// Pick a task, then change your mind to a project — the task must be cleared.
|
||||
await selectTask(form, 'XOR task')
|
||||
await selectProject(page, form, 'XOR project')
|
||||
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
|
||||
const entry = page.locator('[data-cy="timeEntry"]').first()
|
||||
await expect(entry).toContainText('XOR project')
|
||||
await expect(entry).not.toContainText('XOR task')
|
||||
})
|
||||
|
||||
test('starts a timer and stopping it updates the same entry in the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'E2E timer project'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await page.locator('[data-cy="addTimeEntry"]').click()
|
||||
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
await selectProject(page, form, 'E2E timer project')
|
||||
await form.locator('[data-cy="startTimer"]').click()
|
||||
|
||||
const badge = page.locator('[data-cy="timerBadge"]')
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
// The running entry is in the list with an open-ended time range.
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('…')
|
||||
|
||||
await badge.locator('[data-cy="stopTimer"]').click()
|
||||
await expect(badge).not.toBeVisible()
|
||||
|
||||
// The same entry is updated in place — end time set, no longer open-ended.
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
})
|
||||
|
||||
test('does not show another user\'s readable running timer in the header', async ({
|
||||
authenticatedPage: page,
|
||||
currentUser,
|
||||
}) => {
|
||||
const [timerOwner] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
|
||||
const [sharedProject] = await ProjectFactory.create(1, {
|
||||
id: 1001,
|
||||
title: 'Shared active timer project',
|
||||
owner_id: timerOwner.id,
|
||||
}, false)
|
||||
await UserProjectFactory.create(1, {
|
||||
project_id: sharedProject.id,
|
||||
user_id: currentUser.id,
|
||||
permission: 0,
|
||||
}, false)
|
||||
await TimeEntryFactory.create(1, {
|
||||
project_id: sharedProject.id,
|
||||
user_id: timerOwner.id,
|
||||
end_time: null,
|
||||
comment: 'other user running timer',
|
||||
}, false)
|
||||
|
||||
const activeTimerHydrated = page.waitForResponse(response =>
|
||||
response.request().method() === 'GET' &&
|
||||
response.url().includes('/api/v2/time-entries') &&
|
||||
response.url().includes('per_page=1'),
|
||||
)
|
||||
await page.goto('/time-tracking')
|
||||
await activeTimerHydrated
|
||||
|
||||
await expect(page.locator('[data-cy="timeEntry"]').filter({hasText: 'other user running timer'})).toBeVisible()
|
||||
await expect(page.locator('[data-cy="timerBadge"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('hides edit/delete on entries owned by another user', async ({authenticatedPage: page, currentUser}) => {
|
||||
const [other] = await UserFactory.create(1, {id: currentUser.id + 100}, false)
|
||||
const [shared] = await ProjectFactory.create(1, {id: 2001, title: 'Shared log project', owner_id: other.id}, false)
|
||||
await UserProjectFactory.create(1, {project_id: shared.id, user_id: currentUser.id, permission: 0}, false)
|
||||
await TimeEntryFactory.create(1, {id: 10, project_id: shared.id, user_id: other.id, comment: 'theirs'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 11, project_id: shared.id, user_id: currentUser.id, comment: 'mine'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const theirs = page.locator('[data-cy="timeEntry"]').filter({hasText: 'theirs'})
|
||||
const mine = page.locator('[data-cy="timeEntry"]').filter({hasText: 'mine'})
|
||||
await expect(theirs).toBeVisible()
|
||||
await expect(mine).toBeVisible()
|
||||
|
||||
// The current user keeps the controls on their own entry, but not the other's.
|
||||
await expect(mine.locator('[data-cy="editTimeEntry"]')).toBeVisible()
|
||||
await expect(theirs.locator('[data-cy="editTimeEntry"]')).toHaveCount(0)
|
||||
await expect(theirs.locator('[data-cy="deleteTimeEntry"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('task detail: logs an entry and toggles the form with the + button', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'P'}, false)
|
||||
await TaskFactory.create(1, {title: 'Tracked task', project_id: 1}, false)
|
||||
|
||||
const section = await openTaskTimeTracking(page, 1)
|
||||
const form = section.locator('[data-cy="timeEntryForm"]')
|
||||
|
||||
// No entries yet → the form is shown implicitly.
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
await form.locator('[data-cy="smartFill"]').click()
|
||||
await form.locator('[data-cy="saveTimeEntry"]').click()
|
||||
await expect(section.locator('[data-cy="timeEntry"]')).toHaveCount(1)
|
||||
|
||||
// With an entry, the form collapses behind the + button.
|
||||
await expect(form).not.toBeVisible()
|
||||
const addButton = section.locator('[data-cy="addTaskTimeEntry"]')
|
||||
await expect(addButton).toBeVisible()
|
||||
await addButton.click()
|
||||
await expect(form).toBeVisible()
|
||||
})
|
||||
|
||||
test('task detail: stopping a timer updates the entry in the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {title: 'P'}, false)
|
||||
await TaskFactory.create(1, {title: 'Timed task', project_id: 1}, false)
|
||||
|
||||
const section = await openTaskTimeTracking(page, 1)
|
||||
await section.locator('[data-cy="timeEntryForm"] [data-cy="startTimer"]').click()
|
||||
|
||||
const badge = page.locator('[data-cy="timerBadge"]')
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
const entries = section.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('…')
|
||||
|
||||
await badge.locator('[data-cy="stopTimer"]').click()
|
||||
await expect(badge).not.toBeVisible()
|
||||
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).not.toContainText('…')
|
||||
})
|
||||
|
||||
test('edits an entry from the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Edit project'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'original comment'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('original comment')
|
||||
|
||||
await entries.first().locator('[data-cy="editTimeEntry"]').click()
|
||||
const form = page.locator('[data-cy="timeEntryForm"]')
|
||||
const comment = form.locator('[data-cy="timeEntryComment"]')
|
||||
await expect(comment).toHaveValue('original comment')
|
||||
await comment.fill('edited comment')
|
||||
await form.locator('[data-cy="updateTimeEntry"]').click()
|
||||
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('edited comment')
|
||||
await expect(entries.first()).not.toContainText('original comment')
|
||||
})
|
||||
|
||||
test('deletes an entry from the list', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Delete project'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'to be deleted'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(1)
|
||||
|
||||
await entries.first().locator('[data-cy="deleteTimeEntry"]').click()
|
||||
await expect(entries).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filters by project, reflected in the url and restored on reload', async ({authenticatedPage: page}) => {
|
||||
await ProjectFactory.create(1, {id: 1, title: 'Alpha'}, false)
|
||||
await ProjectFactory.create(1, {id: 2, title: 'Beta'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 1, project_id: 1, comment: 'alpha entry'}, false)
|
||||
await TimeEntryFactory.create(1, {id: 2, project_id: 2, comment: 'beta entry'}, false)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
const entries = page.locator('[data-cy="timeEntry"]')
|
||||
await expect(entries).toHaveCount(2)
|
||||
|
||||
// Narrow to project Alpha in the filter modal.
|
||||
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
|
||||
const dialog = page.locator('dialog[open]')
|
||||
const projectInput = dialog.locator('.multiselect').first().locator('input')
|
||||
await projectInput.click()
|
||||
await projectInput.pressSequentially('Alpha', {delay: 30})
|
||||
await dialog.locator('.search-result-button').filter({hasText: 'Alpha'}).first().click()
|
||||
|
||||
// The filter is written to the url.
|
||||
await expect(page).toHaveURL(/[?&]project=1\b/)
|
||||
|
||||
// ...and survives a reload (restored from the url): only Alpha's entry.
|
||||
await page.reload()
|
||||
await expect(entries).toHaveCount(1)
|
||||
await expect(entries.first()).toContainText('Alpha')
|
||||
await expect(page).toHaveURL(/[?&]project=1\b/)
|
||||
})
|
||||
|
||||
test('clearing the date range does not crash the page', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/time-tracking')
|
||||
// The default range surfaces as "Today" in the toolbar label.
|
||||
await expect(page.locator('.time-tracking__range')).toHaveText('Today')
|
||||
|
||||
await page.locator('[data-cy="openTimeTrackingFilters"]').click()
|
||||
// Open the range popup (its trigger is the first button in the picker) and clear via Custom.
|
||||
await page.locator('dialog[open] .datepicker-with-range-container').getByRole('button').first().click()
|
||||
await page.getByRole('button', {name: 'Custom', exact: true}).click()
|
||||
|
||||
// rangeLabel must not call getFullYear on a null date — the page stays alive.
|
||||
await expect(page.locator('.time-tracking__range')).toHaveText('Select a range')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('without the feature licensed', () => {
|
||||
test.beforeEach(async () => {
|
||||
await LicenseFactory.disable()
|
||||
})
|
||||
|
||||
test('hides the sidebar entry and blocks the route', async ({authenticatedPage: page}) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.menu-container').getByRole('link', {name: 'Time tracking'})).toHaveCount(0)
|
||||
|
||||
await page.goto('/time-tracking')
|
||||
await expect(page.locator('[data-cy="addTimeEntry"]')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import {Factory} from '../support/factory'
|
||||
|
||||
// Local "YYYY-MM-DD HH:MM:SS" (the format the DB fixtures use), not ISO-with-Z.
|
||||
// start_time is filtered with datemath day windows that resolve to local time,
|
||||
// and the comparison is lexical — a UTC-stamped value falls outside "today"
|
||||
// near midnight.
|
||||
function sqlDateTime(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export class TimeEntryFactory extends Factory {
|
||||
static table = 'time_entries'
|
||||
|
||||
static factory() {
|
||||
const now = sqlDateTime(new Date())
|
||||
|
||||
return {
|
||||
id: '{increment}',
|
||||
user_id: 1,
|
||||
task_id: 0,
|
||||
project_id: 0,
|
||||
// Completed by default (end set), within today so the default filter shows it.
|
||||
start_time: now,
|
||||
end_time: now,
|
||||
comment: '',
|
||||
created: now,
|
||||
updated: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
23
go.mod
23
go.mod
|
|
@ -35,10 +35,11 @@ require (
|
|||
github.com/coder/websocket v1.8.14
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/d4l3k/messagediff v1.2.1
|
||||
github.com/danielgtaylor/huma/v2 v2.37.3
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
github.com/gabriel-vasile/mimetype v1.4.13
|
||||
github.com/ganigeorgiev/fexpr v0.5.0
|
||||
github.com/getsentry/sentry-go v0.41.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
|
|
@ -47,6 +48,7 @@ require (
|
|||
github.com/gocarina/gocsv v0.0.0-20231116093920-b87c2d0e983a
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/feeds v1.2.0
|
||||
github.com/hashicorp/go-version v1.8.0
|
||||
github.com/hhsnopek/etag v0.0.0-20171206181245-aea95f647346
|
||||
github.com/huandu/go-clone/generic v1.7.3
|
||||
|
|
@ -117,21 +119,24 @@ require (
|
|||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/danielgtaylor/mexpr v1.9.1 // indirect
|
||||
github.com/danielgtaylor/shorthand/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.2 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
|
|
@ -141,10 +146,9 @@ require (
|
|||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/feeds v1.2.0 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
|
|
@ -153,7 +157,7 @@ require (
|
|||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/moby/api v1.53.0 // indirect
|
||||
|
|
@ -186,6 +190,7 @@ require (
|
|||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||
github.com/tj/assert v0.0.3 // indirect
|
||||
github.com/urfave/cli/v2 v2.3.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
|
||||
|
|
@ -198,7 +203,7 @@ require (
|
|||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
sigs.k8s.io/yaml v1.3.0 // indirect
|
||||
|
|
|
|||
46
go.sum
46
go.sum
|
|
@ -96,12 +96,10 @@ github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0
|
|||
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
|
||||
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
|
||||
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
|
|
@ -122,6 +120,12 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
|||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
|
||||
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
||||
github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM=
|
||||
github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g=
|
||||
github.com/danielgtaylor/mexpr v1.9.1 h1:nA9bsGRmNlJeVCPFgGf7WhrLuKag/+iWfOaJ03iKFPI=
|
||||
github.com/danielgtaylor/mexpr v1.9.1/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8=
|
||||
github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ=
|
||||
github.com/danielgtaylor/shorthand/v2 v2.2.0/go.mod h1:t5QfaNf7DPru9ZLIIhPQSO7Gyvajm3euw7LxB/MTUqE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
|
|
@ -144,6 +148,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b h1:+0Xqob+onh+4l9TSWmFyZ4JHqGUiCy5P1muyH8Evfpw=
|
||||
github.com/dustinkirkland/golang-petname v0.0.0-20240422154211-76c06c4bde6b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
|
|
@ -154,16 +160,18 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
|||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/getsentry/sentry-go v0.41.0 h1:q/dQZOlEIb4lhxQSjJhQqtRr3vwrJ6Ahe1C9zv+ryRo=
|
||||
github.com/getsentry/sentry-go v0.41.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
|
|
@ -205,8 +213,8 @@ github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF
|
|||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
|
|
@ -330,8 +338,8 @@ github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6/go.mod h1:W
|
|||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible h1:81Hr6g9bunxXhRv4AZv0anKcS1WwHLMgo6wbBjamJlY=
|
||||
github.com/kolaente/caldav-go v3.0.1-0.20260326091743-a55d55891017+incompatible/go.mod h1:y1UhTNI4g0hVymJrI6yJ5/ohy09hNBeU8iJEZjgdDOw=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
|
|
@ -380,8 +388,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
|
|||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
|
|
@ -534,6 +542,8 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
|||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
|
@ -700,8 +710,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
|
|
|||
40
magefile.go
40
magefile.go
|
|
@ -1510,6 +1510,46 @@ func (Generate) ConfigYAML(commented bool) {
|
|||
generateConfigYAMLFromJSON(DefaultConfigYAMLSamplePath, commented)
|
||||
}
|
||||
|
||||
// ScalarBundle downloads the Scalar API reference standalone JS bundle into
|
||||
// pkg/routes/api/v2/scalar/. Version is pinned to match the Scalar version
|
||||
// used in Huma's internal docs at the time of last update.
|
||||
func (Generate) ScalarBundle() error {
|
||||
const (
|
||||
version = "1.44.20"
|
||||
dest = "pkg/routes/api/v2/scalar/scalar.standalone.js"
|
||||
)
|
||||
url := fmt.Sprintf("https://unpkg.com/@scalar/api-reference@%s/dist/browser/standalone.js", version)
|
||||
|
||||
fmt.Printf("Downloading Scalar bundle %s from %s\n", version, url)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) //nolint:gosec // This is a dev-only mage task and the URL is hard-coded above.
|
||||
if err != nil {
|
||||
return fmt.Errorf("build scalar bundle request: %w", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download scalar bundle: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download scalar bundle: unexpected status %s", resp.Status)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, resp.Body); err != nil {
|
||||
return fmt.Errorf("read scalar bundle body: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dest, buf.Bytes(), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", dest, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Wrote %d bytes to %s\n", buf.Len(), dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
func localBranchExists(ctx context.Context, name string) bool {
|
||||
return exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+name).Run() == nil //nolint:gosec // This is a dev-only mage task and the branch name is supplied by the developer running it.
|
||||
}
|
||||
|
|
|
|||
12
pkg/db/db.go
12
pkg/db/db.go
|
|
@ -525,5 +525,17 @@ func CreateParadeDBIndexes() error {
|
|||
return fmt.Errorf("could not ensure paradedb project index: %w", err)
|
||||
}
|
||||
|
||||
// Create ParadeDB index for time_entries (comment search via MultiFieldSearch)
|
||||
timeEntriesIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_paradedb ON time_entries USING bm25 (id, comment)
|
||||
WITH (
|
||||
key_field='id',
|
||||
text_fields='{
|
||||
"comment": {"fast": true, "record": "freq"}
|
||||
}'
|
||||
)`
|
||||
if _, err := x.Exec(timeEntriesIndexSQL); err != nil {
|
||||
return fmt.Errorf("could not ensure paradedb time entry index: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,21 @@
|
|||
[]
|
||||
- id: 1
|
||||
notifiable_id: 1
|
||||
notification: '{"test":"notification one"}'
|
||||
name: test.notification
|
||||
subject_id: 1
|
||||
read_at: null
|
||||
created: 2022-01-01 00:00:00
|
||||
- id: 2
|
||||
notifiable_id: 1
|
||||
notification: '{"test":"notification two"}'
|
||||
name: test.notification
|
||||
subject_id: 2
|
||||
read_at: null
|
||||
created: 2022-01-02 00:00:00
|
||||
- id: 3
|
||||
notifiable_id: 2
|
||||
notification: '{"test":"other user"}'
|
||||
name: test.notification
|
||||
subject_id: 3
|
||||
read_at: null
|
||||
created: 2022-01-03 00:00:00
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
- id: 1
|
||||
user_id: 1
|
||||
task_id: 1
|
||||
project_id: 0
|
||||
start_time: 2018-12-01 10:00:00
|
||||
end_time: 2018-12-01 11:00:00
|
||||
comment: Time entry on task 1
|
||||
created: 2018-12-01 15:13:12
|
||||
updated: 2018-12-02 15:13:12
|
||||
- id: 2
|
||||
user_id: 1
|
||||
task_id: 0
|
||||
project_id: 1
|
||||
start_time: 2018-12-01 12:00:00
|
||||
end_time: 2018-12-01 13:00:00
|
||||
comment: Standalone entry on project 1
|
||||
created: 2018-12-01 15:13:12
|
||||
updated: 2018-12-02 15:13:12
|
||||
- id: 3
|
||||
user_id: 3
|
||||
task_id: 0
|
||||
project_id: 3
|
||||
start_time: 2018-12-01 12:00:00
|
||||
end_time: 2018-12-01 13:00:00
|
||||
comment: Standalone entry on project 3 by user3
|
||||
created: 2018-12-01 15:13:12
|
||||
updated: 2018-12-02 15:13:12
|
||||
# Running timer (no end_time) on task 1 by user1
|
||||
- id: 4
|
||||
user_id: 1
|
||||
task_id: 1
|
||||
project_id: 0
|
||||
start_time: 2018-12-01 14:00:00
|
||||
comment: Running timer
|
||||
created: 2018-12-01 15:13:12
|
||||
updated: 2018-12-02 15:13:12
|
||||
|
|
@ -8,3 +8,36 @@
|
|||
created_by_id: 1
|
||||
created: 2024-01-01 00:00:00
|
||||
updated: 2024-01-01 00:00:00
|
||||
# Webhooks 2-4 back the v2 permission matrix: project 9 is shared to user1
|
||||
# read-only, 10 write, 11 admin. Update/Delete gate on Project.CanWrite, so the
|
||||
# read-share webhook (#2) must be forbidden while the write/admin ones pass.
|
||||
- id: 2
|
||||
target_url: "https://example.com/webhook-read-share"
|
||||
events: '["task.updated"]'
|
||||
project_id: 9
|
||||
created_by_id: 6
|
||||
created: 2024-01-01 00:00:00
|
||||
updated: 2024-01-01 00:00:00
|
||||
- id: 3
|
||||
target_url: "https://example.com/webhook-write-share"
|
||||
events: '["task.updated"]'
|
||||
project_id: 10
|
||||
created_by_id: 6
|
||||
created: 2024-01-01 00:00:00
|
||||
updated: 2024-01-01 00:00:00
|
||||
- id: 4
|
||||
target_url: "https://example.com/webhook-admin-share"
|
||||
events: '["task.updated"]'
|
||||
project_id: 11
|
||||
created_by_id: 6
|
||||
created: 2024-01-01 00:00:00
|
||||
updated: 2024-01-01 00:00:00
|
||||
# Webhook #5 lives in project 2 (owned by user3, not shared to user1) so the
|
||||
# fully-forbidden update/delete path can be exercised under its real parent.
|
||||
- id: 5
|
||||
target_url: "https://example.com/webhook-forbidden"
|
||||
events: '["task.updated"]'
|
||||
project_id: 2
|
||||
created_by_id: 3
|
||||
created: 2024-01-01 00:00:00
|
||||
updated: 2024-01-01 00:00:00
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ import (
|
|||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
"github.com/c2h5oh/datasize"
|
||||
|
|
@ -205,7 +203,7 @@ func (f *File) Delete(s *xorm.Session) (err error) {
|
|||
return err
|
||||
}
|
||||
|
||||
return keyvalue.DecrBy(metrics.FilesCountKey, 1)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save saves a file to storage
|
||||
|
|
@ -214,5 +212,5 @@ func (f *File) Save(fcontent io.ReadSeeker) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
return keyvalue.IncrBy(metrics.FilesCountKey, 1)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,6 @@ func FullInit() {
|
|||
// Start processing events
|
||||
go func() {
|
||||
models.RegisterListeners()
|
||||
user.RegisterListeners()
|
||||
migrationHandler.RegisterListeners()
|
||||
ws.RegisterListeners()
|
||||
err := events.InitEvents()
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@
|
|||
package metrics
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
|
||||
|
|
@ -36,6 +37,22 @@ const (
|
|||
AttachmentsCountKey = `attachments_count`
|
||||
)
|
||||
|
||||
// countCacheTTL is how long a cached entity count is served before it is recomputed
|
||||
// from the database. The counts are inherently approximate (Prometheus samples them),
|
||||
// so a short staleness window is fine and keeps the cache self-healing — a missed
|
||||
// InvalidateCount call costs at most this much staleness, never a permanent drift.
|
||||
const countCacheTTL = 30 * time.Second
|
||||
|
||||
// countTables maps each count metric key to the database table it counts.
|
||||
var countTables = map[string]string{
|
||||
ProjectCountKey: "projects",
|
||||
UserCountKey: "users",
|
||||
TaskCountKey: "tasks",
|
||||
TeamCountKey: "teams",
|
||||
FilesCountKey: "files",
|
||||
AttachmentsCountKey: "task_attachments",
|
||||
}
|
||||
|
||||
var registry *prometheus.Registry
|
||||
|
||||
func GetRegistry() *prometheus.Registry {
|
||||
|
|
@ -53,7 +70,10 @@ func registerPromMetric(key, description string) {
|
|||
Name: "vikunja_" + key,
|
||||
Help: description,
|
||||
}, func() float64 {
|
||||
count, _ := GetCount(key)
|
||||
count, err := GetCount(key)
|
||||
if err != nil {
|
||||
log.Errorf("Could not get count for metric %s: %s", key, err)
|
||||
}
|
||||
return float64(count)
|
||||
}))
|
||||
if err != nil {
|
||||
|
|
@ -65,8 +85,8 @@ func registerPromMetric(key, description string) {
|
|||
func InitMetrics() {
|
||||
GetRegistry()
|
||||
|
||||
registerPromMetric(ProjectCountKey, "The number of projects on this instance")
|
||||
registerPromMetric(UserCountKey, "The total number of shares on this instance")
|
||||
registerPromMetric(ProjectCountKey, "The total number of projects on this instance")
|
||||
registerPromMetric(UserCountKey, "The total number of users on this instance")
|
||||
registerPromMetric(TaskCountKey, "The total number of tasks on this instance")
|
||||
registerPromMetric(TeamCountKey, "The total number of teams on this instance")
|
||||
registerPromMetric(FilesCountKey, "The total number of files on this instance")
|
||||
|
|
@ -76,26 +96,31 @@ func InitMetrics() {
|
|||
setupActiveLinkSharesMetric()
|
||||
}
|
||||
|
||||
// GetCount returns the current count from keyvalue
|
||||
func GetCount(key string) (count int64, err error) {
|
||||
cnt, exists, err := keyvalue.Get(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !exists {
|
||||
// GetCount returns the current count for the given metric key. The value is counted
|
||||
// directly from the database and cached for countCacheTTL, so repeated scrapes don't
|
||||
// hit the database on every request.
|
||||
func GetCount(key string) (int64, error) {
|
||||
return keyvalue.RememberFor(key, countCacheTTL, func() (int64, error) {
|
||||
return countFromDatabase(key)
|
||||
})
|
||||
}
|
||||
|
||||
// countFromDatabase runs a COUNT(*) for the table backing the given metric key.
|
||||
func countFromDatabase(key string) (int64, error) {
|
||||
table, has := countTables[key]
|
||||
if !has {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if s, is := cnt.(string); is {
|
||||
count, err = strconv.ParseInt(s, 10, 64)
|
||||
} else {
|
||||
count = cnt.(int64)
|
||||
}
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
return
|
||||
return s.Table(table).Count()
|
||||
}
|
||||
|
||||
// SetCount sets the project count to a given value
|
||||
func SetCount(count int64, key string) error {
|
||||
return keyvalue.Put(key, count)
|
||||
// InvalidateCount drops the cached count for a key so the next read recomputes it from
|
||||
// the database. Use it where instant freshness is worth the extra COUNT(*); everywhere
|
||||
// else the countCacheTTL keeps the value reasonably up to date on its own.
|
||||
func InvalidateCount(key string) error {
|
||||
return keyvalue.Del(key)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
// 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 migration
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// Mirrors models.TimeEntry. No partial unique index for the single-active-timer
|
||||
// rule — MySQL has no filtered indexes; it's enforced in the model instead.
|
||||
type TimeEntry20260607132257 struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk"`
|
||||
UserID int64 `xorm:"bigint not null INDEX"`
|
||||
TaskID int64 `xorm:"bigint null INDEX"`
|
||||
ProjectID int64 `xorm:"bigint null INDEX"`
|
||||
StartTime time.Time `xorm:"not null INDEX"`
|
||||
EndTime *time.Time `xorm:"null"`
|
||||
Comment string `xorm:"text null"`
|
||||
Created time.Time `xorm:"created not null"`
|
||||
Updated time.Time `xorm:"updated not null"`
|
||||
}
|
||||
|
||||
func (TimeEntry20260607132257) TableName() string {
|
||||
return "time_entries"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20260607132257",
|
||||
Description: "Add time_entries table",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync(TimeEntry20260607132257{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return tx.DropTables(TimeEntry20260607132257{})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -27,8 +27,15 @@ import (
|
|||
|
||||
var apiTokenRoutes = map[string]APITokenRoute{}
|
||||
|
||||
// apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission)
|
||||
// keys as v1, so a token granted e.g. labels.read_one authorises both
|
||||
// versions. CanDoAPIRoute consults both tables; GetAPITokenRoutes (the /routes
|
||||
// exposure the frontend reads) merges v2-only groups so they're discoverable.
|
||||
var apiTokenRoutesV2 = map[string]APITokenRoute{}
|
||||
|
||||
func init() {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
apiTokenRoutes["caldav"] = APITokenRoute{
|
||||
"access": &RouteDetail{
|
||||
Path: "/dav/*",
|
||||
|
|
@ -50,8 +57,25 @@ type RouteDetail struct {
|
|||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// isV2Path reports whether the given route path lives under /api/v2.
|
||||
func isV2Path(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/v2/") || path == "/api/v2"
|
||||
}
|
||||
|
||||
// stripAPIVersion removes the /api/v1/ or /api/v2/ prefix so both
|
||||
// versions normalise to the same token-permission group name.
|
||||
func stripAPIVersion(path string) string {
|
||||
if stripped := strings.TrimPrefix(path, "/api/v1/"); stripped != path {
|
||||
return stripped
|
||||
}
|
||||
if stripped := strings.TrimPrefix(path, "/api/v2/"); stripped != path {
|
||||
return stripped
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func getRouteGroupName(path string) (finalName string, filteredParts []string) {
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/")
|
||||
parts := strings.Split(stripAPIVersion(path), "/")
|
||||
filteredParts = []string{}
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, ":") {
|
||||
|
|
@ -75,6 +99,9 @@ func getRouteGroupName(path string) (finalName string, filteredParts []string) {
|
|||
// getRouteDetail determines the API permission type from the route's HTTP method and path.
|
||||
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we derive permissions from
|
||||
// the HTTP method and path structure instead of the handler function name.
|
||||
//
|
||||
// v1 and v2 have inverted create/update verbs: v1 uses PUT for create and POST
|
||||
// for update, v2 follows REST conventions (POST create, PUT/PATCH update).
|
||||
func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
|
||||
detail = &RouteDetail{
|
||||
Path: route.Path,
|
||||
|
|
@ -88,6 +115,7 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
|
|||
lastPart = pathParts[len(pathParts)-1]
|
||||
}
|
||||
endsWithParam := strings.HasPrefix(lastPart, ":")
|
||||
v2 := isV2Path(route.Path)
|
||||
|
||||
switch route.Method {
|
||||
case http.MethodGet:
|
||||
|
|
@ -96,10 +124,21 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
|
|||
}
|
||||
return "read_all", detail
|
||||
case http.MethodPut:
|
||||
// PUT is used for creating resources in this codebase
|
||||
if v2 {
|
||||
// v2: PUT replaces an existing resource → update.
|
||||
return "update", detail
|
||||
}
|
||||
// v1: PUT is used for creating resources.
|
||||
return "create", detail
|
||||
case http.MethodPost:
|
||||
// POST is used for updating resources
|
||||
if v2 {
|
||||
// v2: POST creates a new resource on the collection.
|
||||
return "create", detail
|
||||
}
|
||||
// v1: POST is used for updating resources.
|
||||
return "update", detail
|
||||
case http.MethodPatch:
|
||||
// Both versions use PATCH for partial updates.
|
||||
return "update", detail
|
||||
case http.MethodDelete:
|
||||
return "delete", detail
|
||||
|
|
@ -108,9 +147,9 @@ func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
|
|||
return "", detail
|
||||
}
|
||||
|
||||
func ensureAPITokenRoutesGroup(group string) {
|
||||
if _, has := apiTokenRoutes[group]; !has {
|
||||
apiTokenRoutes[group] = make(APITokenRoute)
|
||||
func ensureAPITokenRoutesGroup(target map[string]APITokenRoute, group string) {
|
||||
if _, has := target[group]; !has {
|
||||
target[group] = make(APITokenRoute)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +183,7 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
|
|||
"comments": true,
|
||||
"relations": true,
|
||||
"attachments": true,
|
||||
"time-entries": true,
|
||||
"projects_views": true,
|
||||
"projects_teams": true,
|
||||
"projects_users": true,
|
||||
|
|
@ -183,8 +223,10 @@ func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) b
|
|||
return false
|
||||
}
|
||||
|
||||
// CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens.
|
||||
// The requiresJWT parameter indicates if this route is protected by JWT authentication.
|
||||
// CollectRoutesForAPITokenUsage records a route for token authorisation.
|
||||
// v1 and v2 share group/permission keys derived from the prefix-stripped
|
||||
// path; v2 entries land in apiTokenRoutesV2 so the v1-only frontend UI is
|
||||
// unchanged while CanDoAPIRoute consults both tables.
|
||||
func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
|
||||
|
||||
if route.Method == "echo_route_not_found" {
|
||||
|
|
@ -205,6 +247,17 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
|
|||
return
|
||||
}
|
||||
|
||||
target := apiTokenRoutes
|
||||
if isV2Path(route.Path) {
|
||||
target = apiTokenRoutesV2
|
||||
// AutoPatch's synthesised PATCH and the original PUT both derive the
|
||||
// "update" permission and would clobber each other on the map. Store
|
||||
// only PUT; CanDoAPIRoute accepts PATCH as its alias on the same path.
|
||||
if route.Method == http.MethodPatch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a standard CRUD route using path-based heuristics
|
||||
// In Echo v5, we can no longer rely on route.Name containing "(*WebHandler)"
|
||||
isCRUD := isStandardCRUDRoute(routeGroupName, routeParts, route.Method)
|
||||
|
|
@ -224,67 +277,67 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
|
|||
// Otherwise, we add it to the "other" key.
|
||||
if len(routeParts) == 1 {
|
||||
if routeGroupName == "notifications" && route.Method == http.MethodPost {
|
||||
ensureAPITokenRoutesGroup("notifications")
|
||||
ensureAPITokenRoutesGroup(target, "notifications")
|
||||
|
||||
apiTokenRoutes["notifications"]["mark_all_as_read"] = routeDetail
|
||||
target["notifications"]["mark_all_as_read"] = routeDetail
|
||||
return
|
||||
}
|
||||
|
||||
ensureAPITokenRoutesGroup("other")
|
||||
ensureAPITokenRoutesGroup(target, "other")
|
||||
|
||||
_, exists := apiTokenRoutes["other"][routeGroupName]
|
||||
_, exists := target["other"][routeGroupName]
|
||||
if exists {
|
||||
routeGroupName += "_" + strings.ToLower(route.Method)
|
||||
}
|
||||
apiTokenRoutes["other"][routeGroupName] = routeDetail
|
||||
target["other"][routeGroupName] = routeDetail
|
||||
return
|
||||
}
|
||||
|
||||
subkey := strings.Join(routeParts[1:], "_")
|
||||
|
||||
if _, has := apiTokenRoutes[routeParts[0]]; !has {
|
||||
apiTokenRoutes[routeParts[0]] = make(APITokenRoute)
|
||||
if _, has := target[routeParts[0]]; !has {
|
||||
target[routeParts[0]] = make(APITokenRoute)
|
||||
}
|
||||
|
||||
if _, has := apiTokenRoutes[routeParts[0]][subkey]; has {
|
||||
if _, has := target[routeParts[0]][subkey]; has {
|
||||
subkey += "_" + strings.ToLower(route.Method)
|
||||
}
|
||||
|
||||
apiTokenRoutes[routeParts[0]][subkey] = routeDetail
|
||||
target[routeParts[0]][subkey] = routeDetail
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(routeGroupName, "_bulk") {
|
||||
parent := strings.TrimSuffix(routeGroupName, "_bulk")
|
||||
ensureAPITokenRoutesGroup(parent)
|
||||
ensureAPITokenRoutesGroup(target, parent)
|
||||
|
||||
method, routeDetail := getRouteDetail(route)
|
||||
apiTokenRoutes[parent][method+"_bulk"] = routeDetail
|
||||
target[parent][method+"_bulk"] = routeDetail
|
||||
return
|
||||
}
|
||||
|
||||
_, has := apiTokenRoutes[routeGroupName]
|
||||
_, has := target[routeGroupName]
|
||||
if !has {
|
||||
apiTokenRoutes[routeGroupName] = make(APITokenRoute)
|
||||
target[routeGroupName] = make(APITokenRoute)
|
||||
}
|
||||
|
||||
method, routeDetail := getRouteDetail(route)
|
||||
if method != "" {
|
||||
apiTokenRoutes[routeGroupName][method] = routeDetail
|
||||
target[routeGroupName][method] = routeDetail
|
||||
}
|
||||
|
||||
// Handle task attachments specially - they use custom handlers not WebHandler
|
||||
if routeGroupName == "tasks_attachments" {
|
||||
// PUT is upload (create), GET with :attachment param is download (read_one)
|
||||
if route.Method == http.MethodPut {
|
||||
apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{
|
||||
target[routeGroupName]["create"] = &RouteDetail{
|
||||
Path: route.Path,
|
||||
Method: route.Method,
|
||||
}
|
||||
}
|
||||
if route.Method == http.MethodGet && strings.HasSuffix(route.Path, ":attachment") {
|
||||
apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{
|
||||
target[routeGroupName]["read_one"] = &RouteDetail{
|
||||
Path: route.Path,
|
||||
Method: route.Method,
|
||||
}
|
||||
|
|
@ -293,10 +346,30 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
|
|||
|
||||
}
|
||||
|
||||
// GetAPITokenRoutes exposes the registered scoped-token routes so tests
|
||||
// and the /api/v1/routes handler share a single source of truth.
|
||||
// GetAPITokenRoutes exposes the registered scoped-token routes for the /routes
|
||||
// handler and tests. v1 is the base; v2-only groups and permissions (a v2-only
|
||||
// resource like time-entries has no v1 counterpart) are merged in so tokens can
|
||||
// discover and grant them. Shared (group, permission) keys keep their v1 entry —
|
||||
// CanDoAPIRoute authorises both versions off the same key regardless.
|
||||
func GetAPITokenRoutes() map[string]APITokenRoute {
|
||||
return apiTokenRoutes
|
||||
merged := make(map[string]APITokenRoute, len(apiTokenRoutes))
|
||||
for group, perms := range apiTokenRoutes {
|
||||
merged[group] = make(APITokenRoute, len(perms))
|
||||
for perm, rd := range perms {
|
||||
merged[group][perm] = rd
|
||||
}
|
||||
}
|
||||
for group, perms := range apiTokenRoutesV2 {
|
||||
if merged[group] == nil {
|
||||
merged[group] = make(APITokenRoute)
|
||||
}
|
||||
for perm, rd := range perms {
|
||||
if merged[group][perm] == nil {
|
||||
merged[group][perm] = rd
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage.
|
||||
|
|
@ -317,6 +390,10 @@ func GetAvailableAPIRoutesForToken(c *echo.Context) error {
|
|||
// stored (Path, Method) for that permission matches exactly. This closes
|
||||
// GHSA-v479-vf79-mg83 and the wider method/sub-resource confusion it
|
||||
// enabled. The one exception is the tasks.read_all quirk handled below.
|
||||
// One (group, permission) pair can legitimately match both v1 and v2
|
||||
// routes; we walk apiTokenRoutes and apiTokenRoutesV2 in turn. On v2,
|
||||
// PATCH is accepted as an alias for the stored PUT on the same path
|
||||
// (AutoPatch collapses both onto the "update" permission).
|
||||
func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
|
||||
path := c.Path()
|
||||
if path == "" {
|
||||
|
|
@ -327,23 +404,32 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
|
|||
method := c.Request().Method
|
||||
|
||||
for group, perms := range token.APIPermissions {
|
||||
routes, has := apiTokenRoutes[group]
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
for _, p := range perms {
|
||||
rd := routes[p]
|
||||
if rd == nil {
|
||||
tables := []APITokenRoute{apiTokenRoutes[group], apiTokenRoutesV2[group]}
|
||||
for _, routes := range tables {
|
||||
if routes == nil {
|
||||
continue
|
||||
}
|
||||
if rd.Method == method && rd.Path == path {
|
||||
return true
|
||||
}
|
||||
// Two list endpoints share tasks.read_all but only one
|
||||
// survives collection, so allow either explicitly.
|
||||
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
|
||||
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
|
||||
return true
|
||||
for _, p := range perms {
|
||||
rd := routes[p]
|
||||
if rd == nil {
|
||||
continue
|
||||
}
|
||||
if rd.Method == method && rd.Path == path {
|
||||
return true
|
||||
}
|
||||
// v2: AutoPatch mirrors every PUT as a PATCH on the same
|
||||
// path. PATCH isn't stored (it would clobber PUT under
|
||||
// the same "update" key), so accept it as an alias here.
|
||||
if isV2Path(rd.Path) && rd.Method == http.MethodPut &&
|
||||
method == http.MethodPatch && rd.Path == path {
|
||||
return true
|
||||
}
|
||||
// Two list endpoints share tasks.read_all but only one
|
||||
// survives collection, so allow either explicitly.
|
||||
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
|
||||
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -357,15 +443,20 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
|
|||
func PermissionsAreValid(permissions APIPermissions) (err error) {
|
||||
|
||||
for key, methods := range permissions {
|
||||
routes, has := apiTokenRoutes[key]
|
||||
if !has {
|
||||
// A permission is valid if the group exists in either table. v2-only
|
||||
// resources (no v1 counterpart) live solely in apiTokenRoutesV2, so
|
||||
// validating against the union lets tokens grant them. CanDoAPIRoute
|
||||
// already consults both tables when authorising.
|
||||
v1Routes := apiTokenRoutes[key]
|
||||
v2Routes := apiTokenRoutesV2[key]
|
||||
if v1Routes == nil && v2Routes == nil {
|
||||
return &ErrInvalidAPITokenPermission{
|
||||
Group: key,
|
||||
}
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
if routes[method] == nil {
|
||||
if v1Routes[method] == nil && v2Routes[method] == nil {
|
||||
return &ErrInvalidAPITokenPermission{
|
||||
Group: key,
|
||||
Permission: method,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
|
|
@ -54,3 +55,197 @@ func TestCanDoAPIRoute_BulkLabelTask(t *testing.T) {
|
|||
assert.Equal(t, "/api/v1/tasks/:projecttask/labels/bulk", bulkRoute.Path)
|
||||
assert.Equal(t, "POST", bulkRoute.Method)
|
||||
}
|
||||
|
||||
func TestIsV2Path(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"/api/v2": true,
|
||||
"/api/v2/": true,
|
||||
"/api/v2/labels": true,
|
||||
"/api/v1/labels": false,
|
||||
"/api/v1/api/v2": false, // prefix is authoritative
|
||||
"": false,
|
||||
"/api/v20/labels": false, // only exact /api/v2 prefix counts
|
||||
"/api/v2labels": false,
|
||||
}
|
||||
for path, want := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assert.Equal(t, want, isV2Path(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAPIVersion(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/api/v1/labels": "labels",
|
||||
"/api/v2/labels": "labels",
|
||||
"/api/v2/labels/42": "labels/42",
|
||||
"/api/v1/tasks/bulk": "tasks/bulk",
|
||||
"/api/v3/labels": "/api/v3/labels", // unknown versions pass through
|
||||
"/labels": "/labels",
|
||||
"": "",
|
||||
}
|
||||
for path, want := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
assert.Equal(t, want, stripAPIVersion(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectRoutesV2 verifies that /api/v2 routes are stored in the v2
|
||||
// shadow table under the same (group, permission) keys their v1 counterparts
|
||||
// would use. This is what lets a token scoped on `labels.read_one` authorise
|
||||
// both /api/v1/labels/{id} and /api/v2/labels/{id}.
|
||||
func TestCollectRoutesV2(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/labels"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/labels/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
|
||||
|
||||
// v1 map stays untouched.
|
||||
assert.Empty(t, apiTokenRoutes, "v2 routes must not land in the v1 table")
|
||||
|
||||
labels, has := apiTokenRoutesV2["labels"]
|
||||
require.True(t, has, "labels group should exist in v2 table")
|
||||
assert.Equal(t, "GET", labels["read_all"].Method)
|
||||
assert.Equal(t, "/api/v2/labels", labels["read_all"].Path)
|
||||
assert.Equal(t, "GET", labels["read_one"].Method)
|
||||
assert.Equal(t, "POST", labels["create"].Method)
|
||||
// PUT is the authoritative update verb for API tokens — PATCH is
|
||||
// skipped during collection so it doesn't clobber PUT.
|
||||
assert.Equal(t, "PUT", labels["update"].Method)
|
||||
assert.Equal(t, "DELETE", labels["delete"].Method)
|
||||
}
|
||||
|
||||
// TestCollectRoutes_TimeEntriesV2 verifies the v2-only time-entries resource
|
||||
// lands under a clean "time-entries" group rather than the "other" catch-all,
|
||||
// so its scopes read sensibly for token clients.
|
||||
func TestCollectRoutes_TimeEntriesV2(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/time-entries"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/time-entries/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/time-entries/:id"}, true)
|
||||
|
||||
_, isOther := apiTokenRoutesV2["other"]
|
||||
assert.False(t, isOther, "time-entries CRUD must not fall into the 'other' bucket")
|
||||
|
||||
te, has := apiTokenRoutesV2["time-entries"]
|
||||
require.True(t, has, "time-entries group should exist in the v2 table")
|
||||
assert.Equal(t, "GET", te["read_all"].Method)
|
||||
assert.Equal(t, "/api/v2/time-entries", te["read_all"].Path)
|
||||
assert.Equal(t, "GET", te["read_one"].Method)
|
||||
assert.Equal(t, "POST", te["create"].Method)
|
||||
assert.Equal(t, "PUT", te["update"].Method)
|
||||
assert.Equal(t, "DELETE", te["delete"].Method)
|
||||
}
|
||||
|
||||
// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges
|
||||
// v2-only groups (time-entries has no v1 counterpart) so token clients can
|
||||
// discover and grant them, without mutating the v1 table itself.
|
||||
func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v1/labels"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true)
|
||||
|
||||
routes := GetAPITokenRoutes()
|
||||
|
||||
_, hasLabels := routes["labels"]
|
||||
assert.True(t, hasLabels, "v1 groups stay exposed")
|
||||
|
||||
te, hasTE := routes["time-entries"]
|
||||
require.True(t, hasTE, "v2-only time-entries must be exposed via /routes")
|
||||
assert.Equal(t, "GET", te["read_all"].Method)
|
||||
|
||||
_, v1HasTE := apiTokenRoutes["time-entries"]
|
||||
assert.False(t, v1HasTE, "the merge must not mutate the v1 table")
|
||||
}
|
||||
|
||||
// TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create,
|
||||
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
|
||||
// path.
|
||||
func TestGetRouteDetail_V2Verbs(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path, wantPerm string
|
||||
}{
|
||||
{"GET", "/api/v2/labels", "read_all"},
|
||||
{"GET", "/api/v2/labels/:id", "read_one"},
|
||||
{"POST", "/api/v2/labels", "create"},
|
||||
{"PUT", "/api/v2/labels/:id", "update"},
|
||||
{"PATCH", "/api/v2/labels/:id", "update"},
|
||||
{"DELETE", "/api/v2/labels/:id", "delete"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.method+" "+c.path, func(t *testing.T) {
|
||||
perm, _ := getRouteDetail(echo.RouteInfo{Method: c.method, Path: c.path})
|
||||
assert.Equal(t, c.wantPerm, perm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanDoAPIRoute_V2PatchAliasesPut verifies that a token granted the
|
||||
// "update" permission on a v2 resource can issue PATCH requests against
|
||||
// the same path as the stored PUT route. Huma's AutoPatch synthesises
|
||||
// PATCH for every PUT — the matcher accepts it as an alias so token
|
||||
// holders aren't forced to use PUT exclusively.
|
||||
func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
|
||||
apiTokenRoutes = make(map[string]APITokenRoute)
|
||||
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
||||
apiTokenRoutes["caldav"] = APITokenRoute{
|
||||
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
|
||||
}
|
||||
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
|
||||
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
|
||||
|
||||
token := &APIToken{
|
||||
APIPermissions: APIPermissions{"labels": []string{"update"}},
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
|
||||
t.Run("PUT is allowed (stored verb)", func(t *testing.T) {
|
||||
req := httptest.NewRequest("PUT", "/api/v2/labels/:id", nil)
|
||||
c := e.NewContext(req, httptest.NewRecorder())
|
||||
assert.True(t, CanDoAPIRoute(c, token))
|
||||
})
|
||||
|
||||
t.Run("PATCH is allowed via alias", func(t *testing.T) {
|
||||
req := httptest.NewRequest("PATCH", "/api/v2/labels/:id", nil)
|
||||
c := e.NewContext(req, httptest.NewRecorder())
|
||||
assert.True(t, CanDoAPIRoute(c, token))
|
||||
})
|
||||
|
||||
t.Run("PATCH on a different path is rejected", func(t *testing.T) {
|
||||
req := httptest.NewRequest("PATCH", "/api/v2/projects/:id", nil)
|
||||
c := e.NewContext(req, httptest.NewRecorder())
|
||||
assert.False(t, CanDoAPIRoute(c, token))
|
||||
})
|
||||
|
||||
t.Run("v1 PATCH stays rejected", func(t *testing.T) {
|
||||
// The alias must not bleed onto v1 — v1 has no AutoPatch and
|
||||
// never registers PATCH on update routes.
|
||||
apiTokenRoutes["labels"] = APITokenRoute{
|
||||
"update": &RouteDetail{Path: "/api/v1/labels/:id", Method: "POST"},
|
||||
}
|
||||
v1Token := &APIToken{
|
||||
APIPermissions: APIPermissions{"labels": []string{"update"}},
|
||||
}
|
||||
req := httptest.NewRequest("PATCH", "/api/v1/labels/:id", nil)
|
||||
c := e.NewContext(req, httptest.NewRecorder())
|
||||
assert.False(t, CanDoAPIRoute(c, v1Token))
|
||||
})
|
||||
}
|
||||
|
||||
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
|
||||
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
|
||||
// scenarios in that file) which exercises the full auth pipeline.
|
||||
|
|
|
|||
|
|
@ -37,26 +37,26 @@ type APIPermissions map[string][]string
|
|||
|
||||
type APIToken struct {
|
||||
// The unique, numeric id of this api key.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"token" readOnly:"true" doc:"The unique, numeric id of this api key."`
|
||||
|
||||
// A human-readable name for this token
|
||||
Title string `xorm:"not null" json:"title" valid:"required"`
|
||||
Title string `xorm:"not null" json:"title" valid:"required" minLength:"1" doc:"A human-readable name for this token."`
|
||||
// The actual api key. Only visible after creation.
|
||||
Token string `xorm:"-" json:"token,omitempty"`
|
||||
Token string `xorm:"-" json:"token,omitempty" readOnly:"true" doc:"The cleartext api key. Returned only once, in the response to creating the token; never readable again."`
|
||||
TokenSalt string `xorm:"not null" json:"-"`
|
||||
TokenHash string `xorm:"not null unique" json:"-"`
|
||||
TokenLastEight string `xorm:"not null index varchar(8)" json:"-"`
|
||||
// The permissions this token has. Possible values are available via the /routes endpoint and consist of the keys of the list from that endpoint. For example, if the token should be able to read all tasks as well as update existing tasks, you should add `{"tasks":["read_all","update"]}`.
|
||||
APIPermissions APIPermissions `xorm:"json not null permissions" json:"permissions" valid:"required"`
|
||||
APIPermissions APIPermissions `xorm:"json not null permissions" json:"permissions" valid:"required" doc:"The permissions this token has. Possible values are available via the /routes endpoint and consist of the keys of the list from that endpoint. For example, if the token should be able to read all tasks as well as update existing tasks, you should add {\"tasks\":[\"read_all\",\"update\"]}."`
|
||||
// The date when this key expires.
|
||||
ExpiresAt time.Time `xorm:"not null" json:"expires_at" valid:"required"`
|
||||
ExpiresAt time.Time `xorm:"not null" json:"expires_at" valid:"required" doc:"The date when this key expires."`
|
||||
|
||||
// A timestamp when this api key was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this api key was created. You cannot change this value."`
|
||||
|
||||
// The user ID of the token owner. When creating a token for a bot user, set this
|
||||
// to the bot's ID. If omitted, defaults to the authenticated user.
|
||||
OwnerID int64 `xorm:"bigint not null" json:"owner_id,omitempty" query:"owner_id"`
|
||||
OwnerID int64 `xorm:"bigint not null" json:"owner_id,omitempty" query:"owner_id" doc:"The user ID of the token owner. When creating a token for a bot user, set this to the bot's ID; the bot must be owned by the authenticated user. If omitted, defaults to the authenticated user."`
|
||||
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import (
|
|||
type BotUser struct {
|
||||
// Status shadows user.User.Status so it is included in JSON responses
|
||||
// (the original has json:"-").
|
||||
Status user.Status `xorm:"-" json:"status"`
|
||||
Status user.Status `xorm:"-" json:"status" doc:"The bot's status: 0=active, 2=disabled. Set to 2 to disable the bot, 0 to re-enable it."`
|
||||
|
||||
user.User `xorm:"extends"`
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,13 @@ func (err ValidationHTTPError) GetHTTPCode() int {
|
|||
return err.HTTPCode
|
||||
}
|
||||
|
||||
// GetCode returns Vikunja's numeric domain error code. v2's translateDomainError
|
||||
// reads it to keep the v1 `code` body contract, since this type does not
|
||||
// implement web.HTTPErrorProcessor (the embedded field shadows the method name).
|
||||
func (err ValidationHTTPError) GetCode() int {
|
||||
return err.Code
|
||||
}
|
||||
|
||||
func InvalidFieldError(fields []string) error {
|
||||
return InvalidFieldErrorWithMessage(fields, "Invalid Data")
|
||||
}
|
||||
|
|
@ -2391,3 +2398,201 @@ func (err *ErrOAuthInvalidGrantType) HTTPError() web.HTTPError {
|
|||
Message: "The grant_type is not supported. Use 'authorization_code' or 'refresh_token'.",
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Time Tracking Errors
|
||||
// ====================
|
||||
|
||||
// ErrTimeEntryDoesNotExist represents an error where a time entry does not exist
|
||||
type ErrTimeEntryDoesNotExist struct {
|
||||
TimeEntryID int64
|
||||
}
|
||||
|
||||
// IsErrTimeEntryDoesNotExist checks if an error is ErrTimeEntryDoesNotExist.
|
||||
func IsErrTimeEntryDoesNotExist(err error) bool {
|
||||
_, ok := err.(ErrTimeEntryDoesNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTimeEntryDoesNotExist) Error() string {
|
||||
return fmt.Sprintf("Time entry does not exist [TimeEntryID: %v]", err.TimeEntryID)
|
||||
}
|
||||
|
||||
// ErrCodeTimeEntryDoesNotExist holds the unique world-error code of this error
|
||||
const ErrCodeTimeEntryDoesNotExist = 18001
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTimeEntryDoesNotExist) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusNotFound,
|
||||
Code: ErrCodeTimeEntryDoesNotExist,
|
||||
Message: "This time entry does not exist.",
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTimeEntryInvalidContainer represents an error where a time entry is attached
|
||||
// to both a task and a project, or to neither (violating the XOR invariant).
|
||||
type ErrTimeEntryInvalidContainer struct {
|
||||
TaskID int64
|
||||
ProjectID int64
|
||||
}
|
||||
|
||||
// IsErrTimeEntryInvalidContainer checks if an error is ErrTimeEntryInvalidContainer.
|
||||
func IsErrTimeEntryInvalidContainer(err error) bool {
|
||||
_, ok := err.(ErrTimeEntryInvalidContainer)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTimeEntryInvalidContainer) Error() string {
|
||||
return fmt.Sprintf("Time entry must be attached to exactly one of task or project [TaskID: %v, ProjectID: %v]", err.TaskID, err.ProjectID)
|
||||
}
|
||||
|
||||
// ErrCodeTimeEntryInvalidContainer holds the unique world-error code of this error
|
||||
const ErrCodeTimeEntryInvalidContainer = 18002
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTimeEntryInvalidContainer) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeTimeEntryInvalidContainer,
|
||||
Message: "A time entry must be attached to exactly one of a task or a project.",
|
||||
}
|
||||
}
|
||||
|
||||
// ErrInvalidTimeEntryFilterField represents an error where a time entry filter references a non-filterable field
|
||||
type ErrInvalidTimeEntryFilterField struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
// IsErrInvalidTimeEntryFilterField checks if an error is ErrInvalidTimeEntryFilterField.
|
||||
func IsErrInvalidTimeEntryFilterField(err error) bool {
|
||||
_, ok := err.(ErrInvalidTimeEntryFilterField)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrInvalidTimeEntryFilterField) Error() string {
|
||||
return fmt.Sprintf("Time entry filter field is invalid [Field: %s]", err.Field)
|
||||
}
|
||||
|
||||
// ErrCodeInvalidTimeEntryFilterField holds the unique world-error code of this error
|
||||
const ErrCodeInvalidTimeEntryFilterField = 18003
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrInvalidTimeEntryFilterField) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeInvalidTimeEntryFilterField,
|
||||
Message: fmt.Sprintf("The time entry filter field '%s' is invalid. Filterable fields are user_id, task_id, project_id, start_time and end_time.", err.Field),
|
||||
}
|
||||
}
|
||||
|
||||
// ErrInvalidTimeEntryFilterValue represents an error where a time entry filter value cannot be parsed for its field
|
||||
type ErrInvalidTimeEntryFilterValue struct {
|
||||
Field string
|
||||
Value string
|
||||
}
|
||||
|
||||
// IsErrInvalidTimeEntryFilterValue checks if an error is ErrInvalidTimeEntryFilterValue.
|
||||
func IsErrInvalidTimeEntryFilterValue(err error) bool {
|
||||
_, ok := err.(ErrInvalidTimeEntryFilterValue)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrInvalidTimeEntryFilterValue) Error() string {
|
||||
return fmt.Sprintf("Time entry filter value is invalid [Field: %s, Value: %s]", err.Field, err.Value)
|
||||
}
|
||||
|
||||
// ErrCodeInvalidTimeEntryFilterValue holds the unique world-error code of this error
|
||||
const ErrCodeInvalidTimeEntryFilterValue = 18004
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrInvalidTimeEntryFilterValue) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeInvalidTimeEntryFilterValue,
|
||||
Message: fmt.Sprintf("The value '%s' is not valid for the time entry filter field '%s'.", err.Value, err.Field),
|
||||
}
|
||||
}
|
||||
|
||||
// ErrNoRunningTimer represents an error where a user has no running timer to act on
|
||||
type ErrNoRunningTimer struct {
|
||||
UserID int64
|
||||
}
|
||||
|
||||
// IsErrNoRunningTimer checks if an error is ErrNoRunningTimer.
|
||||
func IsErrNoRunningTimer(err error) bool {
|
||||
_, ok := err.(ErrNoRunningTimer)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrNoRunningTimer) Error() string {
|
||||
return fmt.Sprintf("No running timer [UserID: %d]", err.UserID)
|
||||
}
|
||||
|
||||
// ErrCodeNoRunningTimer holds the unique world-error code of this error
|
||||
const ErrCodeNoRunningTimer = 18005
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrNoRunningTimer) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusNotFound,
|
||||
Code: ErrCodeNoRunningTimer,
|
||||
Message: "You do not have a running timer.",
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTimeEntryAlreadyEnded represents an error where an update tries to clear the
|
||||
// end time of an entry that has already ended (reopening it as a running timer).
|
||||
type ErrTimeEntryAlreadyEnded struct {
|
||||
TimeEntryID int64
|
||||
}
|
||||
|
||||
// IsErrTimeEntryAlreadyEnded checks if an error is ErrTimeEntryAlreadyEnded.
|
||||
func IsErrTimeEntryAlreadyEnded(err error) bool {
|
||||
_, ok := err.(ErrTimeEntryAlreadyEnded)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTimeEntryAlreadyEnded) Error() string {
|
||||
return fmt.Sprintf("Time entry has already ended and cannot be reopened [TimeEntryID: %v]", err.TimeEntryID)
|
||||
}
|
||||
|
||||
// ErrCodeTimeEntryAlreadyEnded holds the unique world-error code of this error
|
||||
const ErrCodeTimeEntryAlreadyEnded = 18006
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTimeEntryAlreadyEnded) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeTimeEntryAlreadyEnded,
|
||||
Message: "A time entry that has already ended cannot be reopened into a running timer. Start a new timer instead.",
|
||||
}
|
||||
}
|
||||
|
||||
// ErrTimeEntryEndBeforeStart represents an error where a time entry's end time
|
||||
// precedes its start time, which would persist a negative interval.
|
||||
type ErrTimeEntryEndBeforeStart struct {
|
||||
TimeEntryID int64
|
||||
}
|
||||
|
||||
// IsErrTimeEntryEndBeforeStart checks if an error is ErrTimeEntryEndBeforeStart.
|
||||
func IsErrTimeEntryEndBeforeStart(err error) bool {
|
||||
_, ok := err.(ErrTimeEntryEndBeforeStart)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTimeEntryEndBeforeStart) Error() string {
|
||||
return fmt.Sprintf("Time entry end time is before its start time [TimeEntryID: %v]", err.TimeEntryID)
|
||||
}
|
||||
|
||||
// ErrCodeTimeEntryEndBeforeStart holds the unique world-error code of this error
|
||||
const ErrCodeTimeEntryEndBeforeStart = 18007
|
||||
|
||||
// HTTPError holds the http error description
|
||||
func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
|
||||
return web.HTTPError{
|
||||
HTTPCode: http.StatusBadRequest,
|
||||
Code: ErrCodeTimeEntryEndBeforeStart,
|
||||
Message: "A time entry's end time cannot be before its start time.",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -362,3 +362,36 @@ type WebhookDeliveryEvent struct {
|
|||
func (w *WebhookDeliveryEvent) Name() string {
|
||||
return "webhook.delivery"
|
||||
}
|
||||
|
||||
// TimeEntryCreatedEvent represents a time entry being created
|
||||
type TimeEntryCreatedEvent struct {
|
||||
TimeEntry *TimeEntry `json:"time_entry"`
|
||||
Doer *user.User `json:"doer"`
|
||||
}
|
||||
|
||||
// Name defines the name for TimeEntryCreatedEvent
|
||||
func (e *TimeEntryCreatedEvent) Name() string {
|
||||
return "time-entry.created"
|
||||
}
|
||||
|
||||
// TimeEntryUpdatedEvent represents a time entry being updated (including a timer being stopped)
|
||||
type TimeEntryUpdatedEvent struct {
|
||||
TimeEntry *TimeEntry `json:"time_entry"`
|
||||
Doer *user.User `json:"doer"`
|
||||
}
|
||||
|
||||
// Name defines the name for TimeEntryUpdatedEvent
|
||||
func (e *TimeEntryUpdatedEvent) Name() string {
|
||||
return "time-entry.updated"
|
||||
}
|
||||
|
||||
// TimeEntryDeletedEvent represents a time entry being deleted
|
||||
type TimeEntryDeletedEvent struct {
|
||||
TimeEntry *TimeEntry `json:"time_entry"`
|
||||
Doer *user.User `json:"doer"`
|
||||
}
|
||||
|
||||
// Name defines the name for TimeEntryDeletedEvent
|
||||
func (e *TimeEntryDeletedEvent) Name() string {
|
||||
return "time-entry.deleted"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,22 +29,22 @@ import (
|
|||
// Label represents a label
|
||||
type Label struct {
|
||||
// The unique, numeric id of this label.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"label"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"label" readOnly:"true" doc:"The unique, numeric id of this label."`
|
||||
// The title of the label. You'll see this one on tasks associated with it.
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250"`
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
|
||||
// The label description.
|
||||
Description string `xorm:"longtext null" json:"description"`
|
||||
Description string `xorm:"longtext null" json:"description" doc:"The label description."`
|
||||
// The color this label has in hex format.
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The color this label has in hex format."`
|
||||
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"`
|
||||
// The user who created this label
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
|
||||
|
||||
// A timestamp when this label was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
|
||||
// A timestamp when this label was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this label was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -107,38 +107,15 @@ func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxPerm
|
|||
return
|
||||
}
|
||||
|
||||
// maxPermission is derived only from label_tasks rows whose task is
|
||||
// actually accessible. The pre-fix code used Get(ll) against the
|
||||
// unrestricted LEFT JOIN, so it could return an inaccessible row and
|
||||
// yield a wrong (or errored) permission.
|
||||
accessibleTaskIDs := []int64{}
|
||||
err = s.Table("label_tasks").
|
||||
Join("INNER", "tasks", "tasks.id = label_tasks.task_id").
|
||||
Where(builder.And(
|
||||
builder.Eq{"label_tasks.label_id": l.ID},
|
||||
accessibleProjects,
|
||||
)).
|
||||
Cols("label_tasks.task_id").
|
||||
Find(&accessibleTaskIDs)
|
||||
// Writes and deletes are owner-only (CanUpdate/CanDelete), so the caller's
|
||||
// max permission is admin for the owner and read for anyone else who can see it.
|
||||
owner, err := l.isLabelOwner(s, a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, taskID := range accessibleTaskIDs {
|
||||
t := &Task{ID: taskID}
|
||||
_, taskPermission, tErr := t.CanRead(s, a)
|
||||
if tErr != nil {
|
||||
err = tErr
|
||||
return
|
||||
}
|
||||
if taskPermission > maxPermission {
|
||||
maxPermission = taskPermission
|
||||
}
|
||||
}
|
||||
|
||||
// Creator-branch fallback: access came from created_by_id with no
|
||||
// accessible task to derive a permission from.
|
||||
if len(accessibleTaskIDs) == 0 {
|
||||
if owner {
|
||||
maxPermission = int(PermissionAdmin)
|
||||
} else {
|
||||
maxPermission = int(PermissionRead)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ type LabelTask struct {
|
|||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
|
||||
TaskID int64 `xorm:"bigint INDEX not null" json:"-" param:"projecttask"`
|
||||
// The label id you want to associate with a task.
|
||||
LabelID int64 `xorm:"bigint INDEX not null" json:"label_id" param:"label"`
|
||||
LabelID int64 `xorm:"bigint INDEX not null" json:"label_id" param:"label" doc:"The id of the label to associate with the task."`
|
||||
// A timestamp when this task was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was added to the task. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
},
|
||||
auth: &user.User{ID: 1},
|
||||
assertMaxPermission: true,
|
||||
wantMaxPermission: int(PermissionRead),
|
||||
wantMaxPermission: int(PermissionAdmin),
|
||||
},
|
||||
{
|
||||
name: "Get nonexistant label",
|
||||
|
|
@ -249,8 +249,8 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
auth: &user.User{ID: 1},
|
||||
},
|
||||
{
|
||||
// Label 4 is attached to tasks in project 1 (user 1 is admin),
|
||||
// so the accessible-tasks iteration must yield PermissionAdmin.
|
||||
// Label 4 is owned by user 2; user 1 can read it via a shared task
|
||||
// but is not the owner, so max permission is read.
|
||||
name: "Get label #4 - other user",
|
||||
fields: fields{
|
||||
ID: 4,
|
||||
|
|
@ -276,7 +276,7 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
},
|
||||
auth: &user.User{ID: 1},
|
||||
assertMaxPermission: true,
|
||||
wantMaxPermission: int(PermissionAdmin),
|
||||
wantMaxPermission: int(PermissionRead),
|
||||
},
|
||||
{
|
||||
// PoC for GHSA-hj5c-mhh2-g7jq: label 6 is reachable only via task
|
||||
|
|
@ -304,12 +304,12 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
},
|
||||
auth: &user.User{ID: 1},
|
||||
assertMaxPermission: true,
|
||||
wantMaxPermission: int(PermissionRead),
|
||||
wantMaxPermission: int(PermissionAdmin),
|
||||
},
|
||||
{
|
||||
// Label 8's only label_tasks row points at inaccessible task 34,
|
||||
// so access must come from the creator branch and the
|
||||
// maxPermission fallback to PermissionRead must kick in.
|
||||
// Label 8's only label_tasks row points at inaccessible task 34, so
|
||||
// access comes from the creator branch; as the owner, user 1's max
|
||||
// permission is admin.
|
||||
name: "creator can read own label only attached to inaccessible task",
|
||||
fields: fields{
|
||||
ID: 8,
|
||||
|
|
@ -324,7 +324,7 @@ func TestLabel_ReadOne(t *testing.T) {
|
|||
},
|
||||
auth: &user.User{ID: 1},
|
||||
assertMaxPermission: true,
|
||||
wantMaxPermission: int(PermissionRead),
|
||||
wantMaxPermission: int(PermissionAdmin),
|
||||
},
|
||||
{
|
||||
// Non-creator must not be able to read an unattached label owned
|
||||
|
|
|
|||
|
|
@ -45,30 +45,30 @@ const (
|
|||
// LinkSharing represents a shared project
|
||||
type LinkSharing struct {
|
||||
// The ID of the shared thing
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"share"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"share" readOnly:"true" doc:"The unique, numeric id of this link share."`
|
||||
// The public id to get this shared project
|
||||
Hash string `xorm:"varchar(40) not null unique" json:"hash" param:"hash"`
|
||||
Hash string `xorm:"varchar(40) not null unique" json:"hash" param:"hash" readOnly:"true" doc:"The public hash used to access the shared project. Generated by the server; ignored on write."`
|
||||
// The name of this link share. All actions someone takes while being authenticated with that link will appear with that name.
|
||||
Name string `xorm:"text null" json:"name"`
|
||||
Name string `xorm:"text null" json:"name" doc:"The name of this link share. All actions someone takes while authenticated through this link will appear under this name."`
|
||||
// The ID of the shared project
|
||||
ProjectID int64 `xorm:"bigint not null" json:"-" param:"project"`
|
||||
// The permission this project is shared with. 0 = Read only, 1 = Read & Write, 2 = Admin. See the docs for more details.
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0"`
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0" doc:"The permission this project is shared with: 0 = read only, 1 = read & write, 2 = admin."`
|
||||
|
||||
// The kind of this link. 0 = undefined, 1 = without password, 2 = with password.
|
||||
SharingType SharingType `xorm:"bigint INDEX not null default 0" json:"sharing_type" valid:"length(0|2)" maximum:"2" default:"0"`
|
||||
SharingType SharingType `xorm:"bigint INDEX not null default 0" json:"sharing_type" valid:"length(0|2)" maximum:"2" default:"0" readOnly:"true" doc:"The kind of this link, derived from whether a password was set: 0 = undefined, 1 = without password, 2 = with password."`
|
||||
|
||||
// The password of this link share. You can only set it, not retrieve it after the link share has been created.
|
||||
Password string `xorm:"text null" json:"password"`
|
||||
Password string `xorm:"text null" json:"password" writeOnly:"true" doc:"The password protecting this link share. Write-only: it can be set on create but is never returned."`
|
||||
|
||||
// The user who shared this project
|
||||
SharedBy *user.User `xorm:"-" json:"shared_by"`
|
||||
SharedBy *user.User `xorm:"-" json:"shared_by" readOnly:"true" doc:"The user who created this link share."`
|
||||
SharedByID int64 `xorm:"bigint INDEX not null" json:"-"`
|
||||
|
||||
// A timestamp when this project was shared. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this share was created. You cannot change this value."`
|
||||
// A timestamp when this share was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this share was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -28,11 +28,24 @@ func (share *LinkSharing) CanRead(s *xorm.Session, a web.Auth) (bool, int, error
|
|||
return false, 0, nil
|
||||
}
|
||||
|
||||
l, err := GetProjectByShareHash(s, share.Hash)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
// A by-id read carries the parent project but no hash, so resolve the
|
||||
// project from ProjectID; only fall back to the hash lookup when ProjectID
|
||||
// is absent (e.g. resolving a share purely by its public hash).
|
||||
var project *Project
|
||||
if share.ProjectID != 0 {
|
||||
var err error
|
||||
project, err = GetProjectSimpleByID(s, share.ProjectID)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
project, err = GetProjectByShareHash(s, share.Hash)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
}
|
||||
return l.CanRead(s, a)
|
||||
return project.CanRead(s, a)
|
||||
}
|
||||
|
||||
// CanDelete implements the delete permission check for a link share
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import (
|
|||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
"code.vikunja.io/api/pkg/modules/keyvalue"
|
||||
"code.vikunja.io/api/pkg/notifications"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
|
|
@ -38,16 +36,6 @@ import (
|
|||
|
||||
// RegisterListeners registers all event listeners
|
||||
func RegisterListeners() {
|
||||
if config.MetricsEnabled.GetBool() {
|
||||
events.RegisterListener((&ProjectCreatedEvent{}).Name(), &IncreaseProjectCounter{})
|
||||
events.RegisterListener((&ProjectDeletedEvent{}).Name(), &DecreaseProjectCounter{})
|
||||
events.RegisterListener((&TaskCreatedEvent{}).Name(), &IncreaseTaskCounter{})
|
||||
events.RegisterListener((&TaskDeletedEvent{}).Name(), &DecreaseTaskCounter{})
|
||||
events.RegisterListener((&TeamDeletedEvent{}).Name(), &DecreaseTeamCounter{})
|
||||
events.RegisterListener((&TeamCreatedEvent{}).Name(), &IncreaseTeamCounter{})
|
||||
events.RegisterListener((&TaskAttachmentCreatedEvent{}).Name(), &IncreaseAttachmentCounter{})
|
||||
events.RegisterListener((&TaskAttachmentDeletedEvent{}).Name(), &DecreaseAttachmentCounter{})
|
||||
}
|
||||
events.RegisterListener((&TaskCommentCreatedEvent{}).Name(), &SendTaskCommentNotification{})
|
||||
events.RegisterListener((&TaskAssigneeCreatedEvent{}).Name(), &SendTaskAssignedNotification{})
|
||||
events.RegisterListener((&TaskDeletedEvent{}).Name(), &SendTaskDeletedNotification{})
|
||||
|
|
@ -99,34 +87,6 @@ func RegisterListeners() {
|
|||
//////
|
||||
// Task Events
|
||||
|
||||
// IncreaseTaskCounter represents a listener
|
||||
type IncreaseTaskCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the IncreaseTaskCounter listener
|
||||
func (s *IncreaseTaskCounter) Name() string {
|
||||
return "task.counter.increase"
|
||||
}
|
||||
|
||||
// Handle is executed when the event IncreaseTaskCounter listens on is fired
|
||||
func (s *IncreaseTaskCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.IncrBy(metrics.TaskCountKey, 1)
|
||||
}
|
||||
|
||||
// DecreaseTaskCounter represents a listener
|
||||
type DecreaseTaskCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the DecreaseTaskCounter listener
|
||||
func (s *DecreaseTaskCounter) Name() string {
|
||||
return "task.counter.decrease"
|
||||
}
|
||||
|
||||
// Handle is executed when the event DecreaseTaskCounter listens on is fired
|
||||
func (s *DecreaseTaskCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.DecrBy(metrics.TaskCountKey, 1)
|
||||
}
|
||||
|
||||
func notifyMentionedUsers(sess *xorm.Session, task *Task, text string, n notifications.NotificationWithSubject) (users map[int64]*user.User, err error) {
|
||||
users, err = FindMentionedUsersInText(sess, text)
|
||||
if err != nil {
|
||||
|
|
@ -583,34 +543,6 @@ func (s *HandleTaskUpdateLastUpdated) Handle(msg *message.Message) (err error) {
|
|||
return sess.Commit()
|
||||
}
|
||||
|
||||
// IncreaseAttachmentCounter represents a listener
|
||||
type IncreaseAttachmentCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the IncreaseAttachmentCounter listener
|
||||
func (s *IncreaseAttachmentCounter) Name() string {
|
||||
return "increase.attachment.counter"
|
||||
}
|
||||
|
||||
// Handle is executed when the event IncreaseAttachmentCounter listens on is fired
|
||||
func (s *IncreaseAttachmentCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.IncrBy(metrics.AttachmentsCountKey, 1)
|
||||
}
|
||||
|
||||
// DecreaseAttachmentCounter represents a listener
|
||||
type DecreaseAttachmentCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the DecreaseAttachmentCounter listener
|
||||
func (s *DecreaseAttachmentCounter) Name() string {
|
||||
return "decrease.attachment.counter"
|
||||
}
|
||||
|
||||
// Handle is executed when the event DecreaseAttachmentCounter listens on is fired
|
||||
func (s *DecreaseAttachmentCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.DecrBy(metrics.AttachmentsCountKey, 1)
|
||||
}
|
||||
|
||||
// UpdateTaskInSavedFilterViews represents a listener
|
||||
type UpdateTaskInSavedFilterViews struct {
|
||||
}
|
||||
|
|
@ -738,28 +670,6 @@ func (l *UpdateTaskInSavedFilterViews) Handle(msg *message.Message) (err error)
|
|||
///////
|
||||
// Project Event Listeners
|
||||
|
||||
type IncreaseProjectCounter struct {
|
||||
}
|
||||
|
||||
func (s *IncreaseProjectCounter) Name() string {
|
||||
return "project.counter.increase"
|
||||
}
|
||||
|
||||
func (s *IncreaseProjectCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.IncrBy(metrics.ProjectCountKey, 1)
|
||||
}
|
||||
|
||||
type DecreaseProjectCounter struct {
|
||||
}
|
||||
|
||||
func (s *DecreaseProjectCounter) Name() string {
|
||||
return "project.counter.decrease"
|
||||
}
|
||||
|
||||
func (s *DecreaseProjectCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.DecrBy(metrics.ProjectCountKey, 1)
|
||||
}
|
||||
|
||||
// SendProjectCreatedNotification represents a listener
|
||||
type SendProjectCreatedNotification struct {
|
||||
}
|
||||
|
|
@ -1259,34 +1169,6 @@ func (wl *WebhookListener) Handle(msg *message.Message) (err error) {
|
|||
///////
|
||||
// Team Events
|
||||
|
||||
// IncreaseTeamCounter represents a listener
|
||||
type IncreaseTeamCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the IncreaseTeamCounter listener
|
||||
func (s *IncreaseTeamCounter) Name() string {
|
||||
return "team.counter.increase"
|
||||
}
|
||||
|
||||
// Handle is executed when the event IncreaseTeamCounter listens on is fired
|
||||
func (s *IncreaseTeamCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.IncrBy(metrics.TeamCountKey, 1)
|
||||
}
|
||||
|
||||
// DecreaseTeamCounter represents a listener
|
||||
type DecreaseTeamCounter struct {
|
||||
}
|
||||
|
||||
// Name defines the name for the DecreaseTeamCounter listener
|
||||
func (s *DecreaseTeamCounter) Name() string {
|
||||
return "team.counter.decrease"
|
||||
}
|
||||
|
||||
// Handle is executed when the event DecreaseTeamCounter listens on is fired
|
||||
func (s *DecreaseTeamCounter) Handle(_ *message.Message) (err error) {
|
||||
return keyvalue.DecrBy(metrics.TeamCountKey, 1)
|
||||
}
|
||||
|
||||
// CleanupTaskAssignmentsAfterTeamRemoval represents a listener
|
||||
type CleanupTaskAssignmentsAfterTeamRemoval struct{}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,5 +19,5 @@ package models
|
|||
// Message is a standard message
|
||||
type Message struct {
|
||||
// A standard message.
|
||||
Message string `json:"message"`
|
||||
Message string `json:"message" readOnly:"true" doc:"A human-readable status message returned by the server."`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// 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 models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMetricsCountFromDatabase verifies that each metric key counts the right table
|
||||
// straight from the database. This guards the count key -> table name mapping; the
|
||||
// caching/expiry/invalidation behaviour itself is covered by the keyvalue RememberFor
|
||||
// tests.
|
||||
func TestMetricsCountFromDatabase(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
metrics.UserCountKey: "users",
|
||||
metrics.ProjectCountKey: "projects",
|
||||
metrics.TaskCountKey: "tasks",
|
||||
metrics.TeamCountKey: "teams",
|
||||
metrics.FilesCountKey: "files",
|
||||
metrics.AttachmentsCountKey: "task_attachments",
|
||||
}
|
||||
|
||||
db.LoadAndAssertFixtures(t)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
for key, table := range cases {
|
||||
t.Run(table, func(t *testing.T) {
|
||||
// Drop any value cached by a previous test so we recompute from the DB.
|
||||
require.NoError(t, metrics.InvalidateCount(key))
|
||||
|
||||
expected, err := s.Table(table).Count()
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := metrics.GetCount(key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, count)
|
||||
assert.Positive(t, count, "fixtures should contain at least one %s", table)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ func GetTables() []interface{} {
|
|||
&TaskUnreadStatus{},
|
||||
&Session{},
|
||||
&OAuthCode{},
|
||||
&TimeEntry{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ type DatabaseNotifications struct {
|
|||
|
||||
// Whether or not to mark this notification as read or unread.
|
||||
// True is read, false is unread.
|
||||
Read bool `xorm:"-" json:"read"`
|
||||
Read bool `xorm:"-" json:"read" doc:"Set true to mark the notification read, false to mark it unread."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -38,52 +38,52 @@ import (
|
|||
// Project represents a project of tasks
|
||||
type Project struct {
|
||||
// The unique, numeric id of this project.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project" readOnly:"true" doc:"The unique, numeric id of this project."`
|
||||
// The title of the project. You'll see this in the overview.
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the project. You'll see this in the overview."`
|
||||
// The description of the project.
|
||||
Description string `xorm:"longtext null" json:"description"`
|
||||
Description string `xorm:"longtext null" json:"description" doc:"The description of the project."`
|
||||
// The unique project short identifier. Used to build task identifiers.
|
||||
Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10"`
|
||||
Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10" doc:"The unique project short identifier. Used to build task identifiers (e.g. PROJ-123)."`
|
||||
// The hex color of this project
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"`
|
||||
HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The hex color of this project, without the leading #."`
|
||||
|
||||
OwnerID int64 `xorm:"bigint INDEX not null" json:"-"`
|
||||
ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"`
|
||||
ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id" doc:"The id of the parent project. 0 if this is a top-level project."`
|
||||
ParentProject *Project `xorm:"-" json:"-"`
|
||||
|
||||
// The user who created this project.
|
||||
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
|
||||
Owner *user.User `xorm:"-" json:"owner" valid:"-" readOnly:"true" doc:"The user who owns this project. Set by the server; ignored on write."`
|
||||
|
||||
// Whether a project is archived.
|
||||
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"`
|
||||
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived" doc:"Whether the project is archived. Archived projects are read-only."`
|
||||
|
||||
// The id of the file this project has set as background
|
||||
BackgroundFileID int64 `xorm:"null" json:"-"`
|
||||
// Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /projects/{projectID}/background
|
||||
BackgroundInformation interface{} `xorm:"-" json:"background_information"`
|
||||
BackgroundInformation interface{} `xorm:"-" json:"background_information" readOnly:"true" doc:"Extra information about the background (e.g. attribution). When not null, the background is available at /projects/{projectID}/background."`
|
||||
// Contains a very small version of the project background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works.
|
||||
BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"`
|
||||
BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash" readOnly:"true" doc:"A small BlurHash preview of the project background, shown until the real background loads. See https://blurha.sh/."`
|
||||
|
||||
// True if a project is a favorite. Favorite projects show up in a separate parent project. This value depends on the user making the call to the api.
|
||||
IsFavorite bool `xorm:"-" json:"is_favorite"`
|
||||
IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the project is a favorite of the requesting user. This value is per-user and depends on who makes the call."`
|
||||
|
||||
// The subscription status for the user reading this project. You can only read this property, use the subscription endpoints to modify it.
|
||||
// Will only returned when retreiving one project.
|
||||
Subscription *Subscription `xorm:"-" json:"subscription,omitempty"`
|
||||
Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription status for this project. Read-only here; use the subscription endpoints to change it. Only returned when retrieving a single project."`
|
||||
|
||||
// The position this project has when querying all projects. See the tasks.position property on how to use this.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
Position float64 `xorm:"double null" json:"position" doc:"The position of this project when listing all projects. See the tasks.position property for how positions work."`
|
||||
|
||||
Views []*ProjectView `xorm:"-" json:"views"`
|
||||
Views []*ProjectView `xorm:"-" json:"views" readOnly:"true" doc:"The views configured for this project. Managed through the project view endpoints."`
|
||||
|
||||
Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"`
|
||||
MaxPermission Permission `xorm:"-" json:"max_permission"`
|
||||
MaxPermission Permission `xorm:"-" json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this project (0 = read, 1 = read/write, 2 = admin)."`
|
||||
|
||||
// A timestamp when this project was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this project was created. You cannot change this value."`
|
||||
// A timestamp when this project was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this project was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -29,18 +29,18 @@ import (
|
|||
// TeamProject defines the relation between a team and a project
|
||||
type TeamProject struct {
|
||||
// The unique, numeric id of this project <-> team relation.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this project <-> team relation."`
|
||||
// The team id.
|
||||
TeamID int64 `xorm:"bigint not null INDEX" json:"team_id" param:"team"`
|
||||
TeamID int64 `xorm:"bigint not null INDEX" json:"team_id" param:"team" doc:"The id of the team that gets access to the project."`
|
||||
// The project id.
|
||||
ProjectID int64 `xorm:"bigint not null INDEX" json:"-" param:"project"`
|
||||
// The permission this team has. 0 = Read only, 1 = Read & Write, 2 = Admin. See the docs for more details.
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0"`
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0" doc:"The permission this team has on the project: 0 = Read only, 1 = Read & Write, 2 = Admin."`
|
||||
|
||||
// A timestamp when this relation was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this relation was created. You cannot change this value."`
|
||||
// A timestamp when this relation was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this relation was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -54,7 +54,7 @@ func (*TeamProject) TableName() string {
|
|||
// TeamWithPermission represents a team, combined with permissions.
|
||||
type TeamWithPermission struct {
|
||||
Team `xorm:"extends"`
|
||||
Permission Permission `json:"permission"`
|
||||
Permission Permission `json:"permission" readOnly:"true" doc:"The permission this team has on the project: 0 = Read only, 1 = Read & Write, 2 = Admin."`
|
||||
}
|
||||
|
||||
// Create creates a new team <-> project relation
|
||||
|
|
|
|||
|
|
@ -30,20 +30,20 @@ import (
|
|||
// ProjectUser represents a project <-> user relation
|
||||
type ProjectUser struct {
|
||||
// The unique, numeric id of this project <-> user relation.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this project <-> user relation."`
|
||||
// The username.
|
||||
Username string `xorm:"-" json:"username" param:"user"`
|
||||
Username string `xorm:"-" json:"username" param:"user" doc:"The username of the user to share with. On update and delete this comes from the URL path, not the body."`
|
||||
// Used internally to reference the user
|
||||
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
// The project id.
|
||||
ProjectID int64 `xorm:"bigint not null INDEX" json:"-" param:"project"`
|
||||
// The permission this user has. 0 = Read only, 1 = Read & Write, 2 = Admin. See the docs for more details.
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0"`
|
||||
Permission Permission `xorm:"bigint INDEX not null default 0" json:"permission" valid:"length(0|2)" maximum:"2" default:"0" doc:"The permission this user has on the project. 0 = Read only, 1 = Read & Write, 2 = Admin."`
|
||||
|
||||
// A timestamp when this relation was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this relation was created. You cannot change this value."`
|
||||
// A timestamp when this relation was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this relation was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -57,7 +57,7 @@ func (*ProjectUser) TableName() string {
|
|||
// UserWithPermission represents a user in combination with the permission it can have on a project
|
||||
type UserWithPermission struct {
|
||||
user.User `xorm:"extends"`
|
||||
Permission Permission `json:"permission"`
|
||||
Permission Permission `json:"permission" readOnly:"true" doc:"The permission this user has on the project. 0 = Read only, 1 = Read & Write, 2 = Admin."`
|
||||
}
|
||||
|
||||
// Create creates a new project <-> user relation
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import (
|
|||
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
|
|
@ -66,6 +67,17 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Schema lets Huma (/api/v2) reflect this type as a string enum. The custom
|
||||
// Marshal/UnmarshalJSON above serialize it as a string, but the underlying Go
|
||||
// type is an int — without this, Huma would generate an integer schema and
|
||||
// reject the string form clients actually send.
|
||||
func (*ProjectViewKind) Schema(_ huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{
|
||||
Type: "string",
|
||||
Enum: []any{"list", "gantt", "table", "kanban"},
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: When adding or changing enum values for ProjectViewKind,
|
||||
// make sure to update the corresponding `enums` tag in the ProjectView struct
|
||||
// to keep the OpenAPI documentation in sync.
|
||||
|
|
@ -123,39 +135,48 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
|
||||
// on ProjectViewKind.Schema for why this is needed.
|
||||
func (*BucketConfigurationModeKind) Schema(_ huma.Registry) *huma.Schema {
|
||||
return &huma.Schema{
|
||||
Type: "string",
|
||||
Enum: []any{"none", "manual", "filter"},
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectViewBucketConfiguration struct {
|
||||
Title string `json:"title"`
|
||||
Filter *TaskCollection `json:"filter"`
|
||||
Title string `json:"title" doc:"The title of the bucket this configuration creates."`
|
||||
Filter *TaskCollection `json:"filter" doc:"The filter query that decides which tasks land in this bucket. See https://vikunja.io/docs/filters."`
|
||||
}
|
||||
|
||||
type ProjectView struct {
|
||||
// The unique numeric id of this view
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view" readOnly:"true" doc:"The unique, numeric id of this view. Set by the server."`
|
||||
// The title of this view
|
||||
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)"`
|
||||
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of this view."`
|
||||
// The project this view belongs to
|
||||
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"`
|
||||
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project" readOnly:"true" doc:"The project this view belongs to. Taken from the URL path; ignored on write."`
|
||||
// The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.
|
||||
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban"`
|
||||
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban" doc:"The kind of this view. One of list, gantt, table or kanban."`
|
||||
|
||||
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
||||
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter"`
|
||||
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
|
||||
// The position of this view in the list. The list of all views will be sorted by this parameter.
|
||||
Position float64 `xorm:"double null" json:"position"`
|
||||
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."`
|
||||
|
||||
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
|
||||
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual"`
|
||||
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
|
||||
// When the bucket configuration mode is not `manual`, this field holds the options of that configuration.
|
||||
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"`
|
||||
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration" doc:"When the bucket configuration mode is filter, holds the title and filter of each bucket."`
|
||||
// The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.
|
||||
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"`
|
||||
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id" doc:"The id of the bucket new tasks without a bucket are added to. Defaults to the leftmost bucket."`
|
||||
// If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.
|
||||
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"`
|
||||
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id" doc:"The id of the done bucket. Tasks moved here are marked done, and tasks marked done are moved here."`
|
||||
|
||||
// A timestamp when this view was updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this view was last updated. You cannot change this value."`
|
||||
// A timestamp when this reaction was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this view was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -248,6 +269,13 @@ func (pv *ProjectView) ReadOne(s *xorm.Session, _ web.Auth) (err error) {
|
|||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /projects/{project}/views/{id} [delete]
|
||||
func (pv *ProjectView) Delete(s *xorm.Session, _ web.Auth) (err error) {
|
||||
// Resolve the view under the path project first: the buckets/positions below are deleted by
|
||||
// view id alone, so without this guard a delete scoped to the wrong parent project would still
|
||||
// wipe another project's buckets and positions while matching zero project_views rows.
|
||||
if _, err = GetProjectViewByIDAndProject(s, pv.ID, pv.ProjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.
|
||||
Where("id = ? AND project_id = ?", pv.ID, pv.ProjectID).
|
||||
Delete(&ProjectView{})
|
||||
|
|
@ -291,6 +319,9 @@ func createProjectView(s *xorm.Session, p *ProjectView, a web.Auth, createBacklo
|
|||
|
||||
if p.BucketConfigurationMode == BucketConfigurationModeFilter {
|
||||
for _, configuration := range p.BucketConfiguration {
|
||||
if configuration == nil {
|
||||
continue
|
||||
}
|
||||
if configuration.Filter != nil && configuration.Filter.Filter != "" {
|
||||
_, err = getTaskFiltersFromFilterString(configuration.Filter.Filter, configuration.Filter.FilterTimezone)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -32,25 +32,25 @@ import (
|
|||
// SavedFilter represents a saved bunch of filters
|
||||
type SavedFilter struct {
|
||||
// The unique numeric id of this saved filter
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter"`
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"filter" readOnly:"true" doc:"The unique, numeric id of this saved filter."`
|
||||
// The actual filters this filter contains
|
||||
Filters *TaskCollection `xorm:"JSON not null" json:"filters" valid:"required"`
|
||||
Filters *TaskCollection `xorm:"JSON not null" json:"filters" valid:"required" doc:"The task filter query and collection options this saved filter wraps."`
|
||||
// The title of the filter.
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
|
||||
Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the filter."`
|
||||
// The description of the filter
|
||||
Description string `xorm:"longtext null" json:"description"`
|
||||
Description string `xorm:"longtext null" json:"description" doc:"The description of the filter."`
|
||||
OwnerID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
|
||||
// The user who owns this filter
|
||||
Owner *user.User `xorm:"-" json:"owner" valid:"-"`
|
||||
Owner *user.User `xorm:"-" json:"owner" valid:"-" readOnly:"true" doc:"The user who owns this filter; set by the server."`
|
||||
|
||||
// True if the filter is a favorite. Favorite filters show up in a separate parent project together with favorite projects.
|
||||
IsFavorite bool `xorm:"default false" json:"is_favorite"`
|
||||
IsFavorite bool `xorm:"default false" json:"is_favorite" doc:"If true, the filter shows up in the Favorites pseudo-project alongside favorite projects."`
|
||||
|
||||
// A timestamp when this filter was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this filter was created. You cannot change this value."`
|
||||
// A timestamp when this filter was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this filter was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -36,23 +36,23 @@ import (
|
|||
// Session represents an active user session with a refresh token.
|
||||
type Session struct {
|
||||
// The session UUID. Embedded in JWTs as the `sid` claim.
|
||||
ID string `xorm:"varchar(36) not null unique pk" json:"id" param:"session"`
|
||||
ID string `xorm:"varchar(36) not null unique pk" json:"id" param:"session" readOnly:"true" doc:"The session UUID; embedded in JWTs as the sid claim."`
|
||||
// The owning user.
|
||||
UserID int64 `xorm:"bigint not null index" json:"-"`
|
||||
// SHA-256 hash of the refresh token. Used for lookup on refresh.
|
||||
TokenHash string `xorm:"varchar(64) not null unique index" json:"-"`
|
||||
// The cleartext refresh token. Only populated on session creation, never stored.
|
||||
RefreshToken string `xorm:"-" json:"refresh_token,omitempty"`
|
||||
RefreshToken string `xorm:"-" json:"refresh_token,omitempty" readOnly:"true" doc:"The cleartext refresh token; returned only once by the login flow, never on listing."`
|
||||
// User-Agent string from the login request.
|
||||
DeviceInfo string `xorm:"text" json:"device_info"`
|
||||
DeviceInfo string `xorm:"text" json:"device_info" readOnly:"true" doc:"User-Agent string captured from the login request."`
|
||||
// IP address from the login request.
|
||||
IPAddress string `xorm:"varchar(100)" json:"ip_address"`
|
||||
IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."`
|
||||
// Whether this is a "remember me" session (controls max refresh lifetime).
|
||||
IsLongSession bool `xorm:"not null default false" json:"-"`
|
||||
// When this session was last refreshed.
|
||||
LastActive time.Time `xorm:"not null" json:"last_active"`
|
||||
LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."`
|
||||
// When this session was created (login time).
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"When this session was created (login time)."`
|
||||
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ func SetupTests() {
|
|||
"task_relations",
|
||||
"task_reminders",
|
||||
"tasks",
|
||||
"time_entries",
|
||||
"team_projects",
|
||||
"team_members",
|
||||
"teams",
|
||||
|
|
|
|||
|
|
@ -96,18 +96,18 @@ const (
|
|||
// Subscription represents a subscription for an entity
|
||||
type Subscription struct {
|
||||
// The numeric ID of the subscription
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id"`
|
||||
ID int64 `xorm:"autoincr not null unique pk" json:"id" readOnly:"true" doc:"The numeric id of the subscription."`
|
||||
|
||||
EntityType SubscriptionEntityType `xorm:"index not null" json:"entity"`
|
||||
EntityType SubscriptionEntityType `xorm:"index not null" json:"entity" readOnly:"true" doc:"The kind of entity this subscription is for. Either project or task; derived server-side from the request path."`
|
||||
Entity string `xorm:"-" json:"-" param:"entity"`
|
||||
// The id of the entity to subscribe to.
|
||||
EntityID int64 `xorm:"bigint index not null" json:"entity_id" param:"entityID"`
|
||||
EntityID int64 `xorm:"bigint index not null" json:"entity_id" param:"entityID" readOnly:"true" doc:"The numeric id of the subscribed entity; taken from the request path."`
|
||||
|
||||
// The user who made this subscription
|
||||
UserID int64 `xorm:"bigint index not null" json:"-"`
|
||||
|
||||
// A timestamp when this subscription was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this subscription was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ import (
|
|||
type TaskAssginee struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
|
||||
TaskID int64 `xorm:"bigint INDEX not null" json:"-" param:"projecttask"`
|
||||
UserID int64 `xorm:"bigint INDEX not null" json:"user_id" param:"user"`
|
||||
Created time.Time `xorm:"created not null"`
|
||||
UserID int64 `xorm:"bigint INDEX not null" json:"user_id" param:"user" doc:"The id of the user to assign to the task. The user must have access to the task's project."`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this assignment was created. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -32,22 +32,20 @@ type TaskCollection struct {
|
|||
ProjectID int64 `param:"project" json:"-"`
|
||||
ProjectViewID int64 `param:"view" json:"-"`
|
||||
|
||||
Search string `query:"s" json:"s"`
|
||||
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
|
||||
|
||||
// The query parameter to sort by. This is for ex. done, priority, etc.
|
||||
SortBy []string `query:"sort_by" json:"sort_by"`
|
||||
SortByArr []string `query:"sort_by[]" json:"-"`
|
||||
SortBy []string `query:"sort_by" json:"sort_by" doc:"The fields to sort by, for example done or priority."`
|
||||
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
|
||||
OrderBy []string `query:"order_by" json:"order_by"`
|
||||
OrderByArr []string `query:"order_by[]" json:"-"`
|
||||
OrderBy []string `query:"order_by" json:"order_by" doc:"The order for each sort_by field, either asc or desc. Defaults to asc."`
|
||||
|
||||
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
|
||||
Filter string `query:"filter" json:"filter"`
|
||||
Filter string `query:"filter" json:"filter" doc:"The filter query to match tasks by. See https://vikunja.io/docs/filters."`
|
||||
// The time zone which should be used for date match (statements like "now" resolve to different actual times)
|
||||
FilterTimezone string `query:"filter_timezone" json:"-"`
|
||||
|
||||
// If set to true, the result will also include null values
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
|
||||
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls" doc:"If true, the result also includes tasks whose filtered field is null."`
|
||||
|
||||
// If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a
|
||||
// second step, will fetch all of these subtasks. This may result in more tasks than the
|
||||
|
|
@ -56,8 +54,7 @@ type TaskCollection struct {
|
|||
// If set to `reactions`, the reactions of each task will be present in the response.
|
||||
// If set to `comments`, the first 50 comments of each task will be present in the response.
|
||||
// You can set this multiple times with different values.
|
||||
Expand []TaskCollectionExpandable `query:"expand" json:"-"`
|
||||
ExpandArr []TaskCollectionExpandable `query:"expand[]" json:"-"`
|
||||
Expand []TaskCollectionExpandable `query:"expand" json:"-"`
|
||||
|
||||
isSavedFilter bool
|
||||
|
||||
|
|
@ -72,6 +69,7 @@ const TaskCollectionExpandBuckets TaskCollectionExpandable = `buckets`
|
|||
const TaskCollectionExpandReactions TaskCollectionExpandable = `reactions`
|
||||
const TaskCollectionExpandComments TaskCollectionExpandable = `comments`
|
||||
const TaskCollectionExpandCommentCount TaskCollectionExpandable = `comment_count`
|
||||
const TaskCollectionExpandTimeEntriesCount TaskCollectionExpandable = `time_entries_count`
|
||||
const TaskCollectionExpandIsUnread TaskCollectionExpandable = `is_unread`
|
||||
|
||||
// Validate validates if the TaskCollectionExpandable value is valid.
|
||||
|
|
@ -87,11 +85,13 @@ func (t TaskCollectionExpandable) Validate() error {
|
|||
return nil
|
||||
case TaskCollectionExpandCommentCount:
|
||||
return nil
|
||||
case TaskCollectionExpandTimeEntriesCount:
|
||||
return nil
|
||||
case TaskCollectionExpandIsUnread:
|
||||
return nil
|
||||
}
|
||||
|
||||
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, is_unread")
|
||||
return InvalidFieldErrorWithMessage([]string{"expand"}, "Expand must be one of the following values: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread")
|
||||
}
|
||||
|
||||
func validateTaskField(fieldName string) error {
|
||||
|
|
@ -107,18 +107,6 @@ func validateTaskField(fieldName string) error {
|
|||
}
|
||||
|
||||
func getTaskFilterOptsFromCollection(tf *TaskCollection, projectView *ProjectView) (opts *taskSearchOptions, err error) {
|
||||
if len(tf.SortByArr) > 0 {
|
||||
tf.SortBy = append(tf.SortBy, tf.SortByArr...)
|
||||
}
|
||||
|
||||
if len(tf.OrderByArr) > 0 {
|
||||
tf.OrderBy = append(tf.OrderBy, tf.OrderByArr...)
|
||||
}
|
||||
|
||||
if len(tf.ExpandArr) > 0 {
|
||||
tf.Expand = append(tf.Expand, tf.ExpandArr...)
|
||||
}
|
||||
|
||||
var sort = make([]*sortParam, 0, len(tf.SortBy))
|
||||
for i, s := range tf.SortBy {
|
||||
param := &sortParam{
|
||||
|
|
@ -272,18 +260,12 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
|||
// By prepending sort options before the saved ones from the filter, we make sure the supplied sort
|
||||
// options via query take precedence over the rest.
|
||||
|
||||
sortby := append(tf.SortBy, tf.SortByArr...)
|
||||
sortby = append(sortby, sf.Filters.SortBy...)
|
||||
sortby = append(sortby, sf.Filters.SortByArr...)
|
||||
sortby := append(tf.SortBy, sf.Filters.SortBy...)
|
||||
|
||||
orderby := append(tf.OrderBy, tf.OrderByArr...)
|
||||
orderby = append(orderby, sf.Filters.OrderBy...)
|
||||
orderby = append(orderby, sf.Filters.OrderByArr...)
|
||||
orderby := append(tf.OrderBy, sf.Filters.OrderBy...)
|
||||
|
||||
sf.Filters.SortBy = sortby
|
||||
sf.Filters.SortByArr = nil
|
||||
sf.Filters.OrderBy = orderby
|
||||
sf.Filters.OrderByArr = nil
|
||||
|
||||
if sf.Filters.FilterTimezone == "" {
|
||||
u, err := user.GetUserByID(s, a.GetID())
|
||||
|
|
@ -297,8 +279,7 @@ func (tf *TaskCollection) ReadAll(s *xorm.Session, a web.Auth, search string, pa
|
|||
tc.ProjectViewID = tf.ProjectViewID
|
||||
tc.ProjectID = tf.ProjectID
|
||||
tc.isSavedFilter = true
|
||||
tc.Expand = append(tf.Expand, tf.ExpandArr...)
|
||||
tc.ExpandArr = nil
|
||||
tc.Expand = tf.Expand
|
||||
|
||||
if tf.Filter != "" {
|
||||
if tc.Filter != "" {
|
||||
|
|
|
|||
|
|
@ -170,20 +170,16 @@ func parseFilterFromExpression(f fexpr.ExprGroup, loc *time.Location) (filter *t
|
|||
return filter, nil
|
||||
}
|
||||
|
||||
func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) {
|
||||
|
||||
if filter == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// preprocessFilterString rewrites the human filter syntax (in / not in / like)
|
||||
// into fexpr sigils and quotes bare values so fexpr.Parse accepts them. Shared
|
||||
// by every entity that filters with the task grammar.
|
||||
func preprocessFilterString(filter string) string {
|
||||
filter = strings.ReplaceAll(filter, " not in ", " "+string(fexpr.SignAnyNeq)+" ")
|
||||
filter = strings.ReplaceAll(filter, " in ", " ?= ")
|
||||
filter = strings.ReplaceAll(filter, " like ", " ~ ")
|
||||
|
||||
// Regex pattern to match filter expressions
|
||||
re := regexp.MustCompile(`(\w+)\s*(>=|<=|!=|~|\?=|\?!=|=|>|<)\s*([^&|()]+)`)
|
||||
|
||||
filter = re.ReplaceAllStringFunc(filter, func(match string) string {
|
||||
return re.ReplaceAllStringFunc(filter, func(match string) string {
|
||||
parts := re.FindStringSubmatch(match)
|
||||
if len(parts) != 4 {
|
||||
return match
|
||||
|
|
@ -193,16 +189,24 @@ func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filte
|
|||
comparator := parts[2]
|
||||
value := strings.TrimSpace(parts[3])
|
||||
|
||||
// Check if the value is already quoted
|
||||
// Already quoted — leave as-is
|
||||
if (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) ||
|
||||
(strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) {
|
||||
return field + " " + comparator + " " + value
|
||||
}
|
||||
|
||||
// Quote the value
|
||||
quotedValue := "'" + strings.ReplaceAll(value, "'", "\\'") + "'"
|
||||
return field + " " + comparator + " " + quotedValue
|
||||
})
|
||||
}
|
||||
|
||||
func getTaskFiltersFromFilterString(filter string, filterTimezone string) (filters []*taskFilter, err error) {
|
||||
|
||||
if filter == "" {
|
||||
return
|
||||
}
|
||||
|
||||
filter = preprocessFilterString(filter)
|
||||
|
||||
parsedFilter, err := fexpr.Parse(filter)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -30,18 +30,18 @@ import (
|
|||
|
||||
// TaskComment represents a task comment
|
||||
type TaskComment struct {
|
||||
ID int64 `xorm:"autoincr pk unique not null" json:"id" param:"commentid"`
|
||||
Comment string `xorm:"text not null" json:"comment" valid:"dbtext,required"`
|
||||
ID int64 `xorm:"autoincr pk unique not null" json:"id" param:"commentid" readOnly:"true" doc:"The unique, numeric id of this comment."`
|
||||
Comment string `xorm:"text not null" json:"comment" valid:"dbtext,required" doc:"The comment text. May contain HTML; mentions are parsed and notify the mentioned users."`
|
||||
AuthorID int64 `xorm:"not null" json:"-"`
|
||||
Author *user.User `xorm:"-" json:"author"`
|
||||
Author *user.User `xorm:"-" json:"author" readOnly:"true" doc:"The user who wrote the comment. Set from the authenticated user on create; ignored on write."`
|
||||
TaskID int64 `xorm:"index not null" json:"-" param:"task"`
|
||||
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions"`
|
||||
Reactions ReactionMap `xorm:"-" json:"reactions" readOnly:"true" doc:"The reactions on this comment, keyed by reaction value. Managed through the reactions endpoints, not by writing here."`
|
||||
|
||||
OrderBy string `xorm:"-" json:"-" query:"order_by"`
|
||||
|
||||
Created time.Time `xorm:"created" json:"created"`
|
||||
Updated time.Time `xorm:"updated" json:"updated"`
|
||||
Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this comment was created. You cannot change this value."`
|
||||
Updated time.Time `xorm:"updated" json:"updated" readOnly:"true" doc:"A timestamp when this comment was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -167,7 +167,7 @@ func (tc *TaskComment) Delete(s *xorm.Session, a web.Auth) error {
|
|||
// @Failure 404 {object} web.HTTPError "The task comment was not found."
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/{taskID}/comments/{commentID} [post]
|
||||
func (tc *TaskComment) Update(s *xorm.Session, _ web.Auth) error {
|
||||
func (tc *TaskComment) Update(s *xorm.Session, a web.Auth) error {
|
||||
updated, err := s.
|
||||
ID(tc.ID).
|
||||
Cols("comment").
|
||||
|
|
@ -185,10 +185,17 @@ func (tc *TaskComment) Update(s *xorm.Session, _ web.Auth) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Resolve the doer from the session, not from tc.Author: the latter is bound
|
||||
// from the request body and could be omitted (nil) or spoofed.
|
||||
doer, err := GetUserOrLinkShareUser(s, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
events.DispatchOnCommit(s, &TaskCommentUpdatedEvent{
|
||||
Task: &task,
|
||||
Comment: tc,
|
||||
Doer: tc.Author,
|
||||
Doer: doer,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ type TaskDuplicate struct {
|
|||
TaskID int64 `json:"-" param:"projecttask"`
|
||||
|
||||
// The duplicated task
|
||||
Task *Task `json:"duplicated_task,omitempty"`
|
||||
Task *Task `json:"duplicated_task,omitempty" readOnly:"true" doc:"The newly created duplicate task, populated by the server in the response."`
|
||||
|
||||
web.Permissions `json:"-"`
|
||||
web.CRUDable `json:"-"`
|
||||
|
|
|
|||
|
|
@ -140,9 +140,11 @@ type Task struct {
|
|||
// Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`.
|
||||
CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"`
|
||||
|
||||
// Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`.
|
||||
TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty"`
|
||||
|
||||
// Behaves exactly the same as with the TaskCollection.Expand parameter
|
||||
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
|
||||
ExpandArr []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand[]"`
|
||||
Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"`
|
||||
|
||||
// The position of the task - any task project can be sorted as usual by this parameter.
|
||||
// When accessing tasks via views with buckets, this is primarily used to sort them based on a range.
|
||||
|
|
@ -768,6 +770,11 @@ func addMoreInfoToTasks(s *xorm.Session, taskMap map[int64]*Task, a web.Auth, vi
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case TaskCollectionExpandTimeEntriesCount:
|
||||
err = addTimeEntriesCountToTasks(s, a, taskIDs, taskMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case TaskCollectionExpandIsUnread:
|
||||
err = addIsUnreadToTasks(s, taskIDs, taskMap, a)
|
||||
if err != nil {
|
||||
|
|
@ -1966,7 +1973,6 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
|
|||
// @Router /tasks/{id} [get]
|
||||
func (t *Task) ReadOne(s *xorm.Session, a web.Auth) (err error) {
|
||||
|
||||
t.Expand = append(t.Expand, t.ExpandArr...)
|
||||
expand := t.Expand
|
||||
if err = t.resolveIDFromProjectAndIndex(s); err != nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ func (t *Task) CanCreate(s *xorm.Session, a web.Auth) (bool, error) {
|
|||
|
||||
// CanRead determines if a user can read a task
|
||||
func (t *Task) CanRead(s *xorm.Session, a web.Auth) (canRead bool, maxPermission int, err error) {
|
||||
t.Expand = append(t.Expand, t.ExpandArr...)
|
||||
expand := t.Expand
|
||||
if err = t.resolveIDFromProjectAndIndex(s); err != nil {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -169,6 +169,10 @@ func (tm *TeamMember) Update(s *xorm.Session, _ web.Auth) (err error) {
|
|||
Where("team_id = ? AND user_id = ?", tm.TeamID, tm.UserID).
|
||||
Cols("admin").
|
||||
Update(ttm)
|
||||
tm.Admin = ttm.Admin // Since we're returning the updated permissions object
|
||||
|
||||
// Carry the persisted row back onto tm so the response has id/created, keeping Username (xorm:"-").
|
||||
username := tm.Username
|
||||
*tm = *ttm
|
||||
tm.Username = username
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,32 +33,35 @@ import (
|
|||
// Team holds a team object
|
||||
type Team struct {
|
||||
// The unique, numeric id of this team.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"team"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"team" readOnly:"true" doc:"The unique, numeric id of this team."`
|
||||
// The name of this team.
|
||||
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"`
|
||||
Name string `xorm:"varchar(250) not null" json:"name" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The name of this team."`
|
||||
// The team's description.
|
||||
Description string `xorm:"longtext null" json:"description"`
|
||||
CreatedByID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
// The team's external id provided by the openid or ldap provider
|
||||
ExternalID string `xorm:"varchar(250) null" maxLength:"250" json:"external_id"`
|
||||
ExternalID string `xorm:"varchar(250) null" maxLength:"250" json:"external_id" readOnly:"true" doc:"The team's external id, set by the openid or ldap provider that created it. Read-only for clients."`
|
||||
// Contains the issuer extracted from the vikunja_groups claim if this team was created through oidc
|
||||
Issuer string `xorm:"text null" json:"-"`
|
||||
|
||||
// The user who created this team.
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this team. Set by the server."`
|
||||
// An array of all members in this team.
|
||||
Members []*TeamUser `xorm:"-" json:"members"`
|
||||
Members []*TeamUser `xorm:"-" json:"members" readOnly:"true" doc:"All members of this team. Managed through the team members endpoints, not by writing to this field."`
|
||||
|
||||
// A timestamp when this relation was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created" json:"created"`
|
||||
Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this team was created. You cannot change this value."`
|
||||
// A timestamp when this relation was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated" json:"updated"`
|
||||
Updated time.Time `xorm:"updated" json:"updated" readOnly:"true" doc:"A timestamp when this team was last updated. You cannot change this value."`
|
||||
|
||||
// Defines wether the team should be publicly discoverable when sharing a project
|
||||
IsPublic bool `xorm:"not null default false" json:"is_public"`
|
||||
IsPublic bool `xorm:"not null default false" json:"is_public" doc:"Whether the team should be publicly discoverable when sharing a project. Only effective if public teams are enabled on the instance."`
|
||||
|
||||
// Query parameter controlling whether to include public projects or not
|
||||
IncludePublic bool `xorm:"-" query:"include_public" json:"include_public"`
|
||||
// Query-only flag controlling whether public teams the user is not a member
|
||||
// of are included when listing. It is never part of the request or response
|
||||
// body (json:"-") — v1 binds it from the query string via the query tag, and
|
||||
// the v2 list handler takes it as a dedicated query field and sets it here.
|
||||
IncludePublic bool `xorm:"-" query:"include_public" json:"-"`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -72,18 +75,18 @@ func (*Team) TableName() string {
|
|||
// TeamMember defines the relationship between a user and a team
|
||||
type TeamMember struct {
|
||||
// The unique, numeric id of this team member relation.
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this team member relation. Set by the server."`
|
||||
// The team id.
|
||||
TeamID int64 `xorm:"bigint not null INDEX" json:"-" param:"team"`
|
||||
// The username of the member. We use this to prevent automated user id entering.
|
||||
Username string `xorm:"-" json:"username" param:"user"`
|
||||
Username string `xorm:"-" json:"username" param:"user" valid:"required" minLength:"1" doc:"The username of the member."`
|
||||
// Used under the hood to manage team members
|
||||
UserID int64 `xorm:"bigint not null INDEX" json:"-"`
|
||||
// Whether or not the member is an admin of the team. See the docs for more about what a team admin can do
|
||||
Admin bool `xorm:"null" json:"admin"`
|
||||
Admin bool `xorm:"null" json:"admin" doc:"Whether the member is an admin of the team. Team admins can add and remove members and toggle other members' admin status."`
|
||||
|
||||
// A timestamp when this relation was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this member was added to the team. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,441 @@
|
|||
// 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 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) {
|
||||
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 the feature is unlicensed, 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
|
||||
}
|
||||
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
// 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 models
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
|
||||
"github.com/ganigeorgiev/fexpr"
|
||||
"github.com/jszwedko/go-datemath"
|
||||
"xorm.io/builder"
|
||||
)
|
||||
|
||||
// entriesForProjectCond matches time entries belonging to a project given a
|
||||
// predicate over a project_id column: standalone entries whose own project_id
|
||||
// matches, plus task-attached entries whose task currently lives in a matching
|
||||
// project. Tasks move between projects, so the project is resolved via the task
|
||||
// at query time rather than denormalized. Used for both permission scoping and
|
||||
// the project_id filter.
|
||||
func entriesForProjectCond(projectIDCond builder.Cond) builder.Cond {
|
||||
return builder.Or(
|
||||
projectIDCond,
|
||||
builder.In("task_id",
|
||||
builder.Select("id").From("tasks").Where(projectIDCond),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// timeEntryFilterCond parses a task-style filter string into a condition over
|
||||
// the time_entries table, or nil for an empty filter. Filterable fields:
|
||||
// user_id, task_id, project_id (ints / in-lists), start_time, end_time (dates,
|
||||
// datemath, or the literal null for running timers). comment is deliberately
|
||||
// not filterable — text matching belongs to search.
|
||||
func timeEntryFilterCond(filter, filterTimezone string) (builder.Cond, error) {
|
||||
if filter == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parsed, err := fexpr.Parse(preprocessFilterString(filter))
|
||||
if err != nil {
|
||||
return nil, &ErrInvalidFilterExpression{Expression: filter, ExpressionError: err}
|
||||
}
|
||||
|
||||
loc := config.GetTimeZone()
|
||||
if filterTimezone != "" {
|
||||
loc, err = time.LoadLocation(filterTimezone)
|
||||
if err != nil {
|
||||
return nil, &ErrInvalidTimezone{Name: filterTimezone, LoadError: err}
|
||||
}
|
||||
}
|
||||
|
||||
return buildTimeEntryFilterCond(parsed, loc)
|
||||
}
|
||||
|
||||
func buildTimeEntryFilterCond(groups []fexpr.ExprGroup, loc *time.Location) (builder.Cond, error) {
|
||||
conds := make([]builder.Cond, 0, len(groups))
|
||||
joins := make([]taskFilterConcatinator, 0, len(groups))
|
||||
|
||||
for _, g := range groups {
|
||||
join := filterConcatAnd
|
||||
if g.Join == fexpr.JoinOr {
|
||||
join = filterConcatOr
|
||||
}
|
||||
|
||||
var (
|
||||
cond builder.Cond
|
||||
err error
|
||||
)
|
||||
switch item := g.Item.(type) {
|
||||
case []fexpr.ExprGroup: // a parenthesized sub-expression
|
||||
cond, err = buildTimeEntryFilterCond(item, loc)
|
||||
case fexpr.Expr:
|
||||
var comparator taskFilterComparator
|
||||
comparator, err = getFilterComparatorFromOp(item.Op)
|
||||
if err == nil {
|
||||
cond, err = resolveTimeEntryFilter(item.Left.Literal, comparator, item.Right.Literal, loc)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conds = append(conds, cond)
|
||||
joins = append(joins, join)
|
||||
}
|
||||
|
||||
if len(conds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := conds[0]
|
||||
for i := 1; i < len(conds); i++ {
|
||||
if joins[i] == filterConcatOr {
|
||||
result = builder.Or(result, conds[i])
|
||||
continue
|
||||
}
|
||||
result = builder.And(result, conds[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveTimeEntryFilter(field string, comparator taskFilterComparator, raw string, loc *time.Location) (builder.Cond, error) {
|
||||
switch field {
|
||||
case "user_id", "task_id":
|
||||
value, err := timeEntryIntFilterValue(raw, comparator)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
|
||||
}
|
||||
return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator, isNumeric: true}, false)
|
||||
|
||||
case "project", "project_id":
|
||||
value, err := timeEntryIntFilterValue(raw, comparator)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: "project_id", Value: raw}
|
||||
}
|
||||
// Build membership positively (standalone-in-project OR task-in-project)
|
||||
// and negate the whole set for != / not in. Negating project_id alone would
|
||||
// wrongly match task-attached entries, whose own project_id is 0.
|
||||
positive, negate := comparator, false
|
||||
if comparator == taskFilterComparatorNotEquals {
|
||||
positive, negate = taskFilterComparatorEquals, true
|
||||
}
|
||||
if comparator == taskFilterComparatorNotIn {
|
||||
positive, negate = taskFilterComparatorIn, true
|
||||
}
|
||||
inner, err := getFilterCond(&taskFilter{field: "project_id", value: value, comparator: positive, isNumeric: true}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cond := entriesForProjectCond(inner)
|
||||
if negate {
|
||||
cond = builder.Not{cond}
|
||||
}
|
||||
return cond, nil
|
||||
|
||||
case "start_time", "end_time":
|
||||
if raw == "null" {
|
||||
return nullTimeFilterCond(field, comparator)
|
||||
}
|
||||
value, err := timeEntryTimeFilterValue(raw, loc)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: raw}
|
||||
}
|
||||
return getFilterCond(&taskFilter{field: field, value: value, comparator: comparator}, false)
|
||||
|
||||
default:
|
||||
return nil, ErrInvalidTimeEntryFilterField{Field: field}
|
||||
}
|
||||
}
|
||||
|
||||
// nullTimeFilterCond handles `end_time = null` (running timers) and its negation.
|
||||
func nullTimeFilterCond(field string, comparator taskFilterComparator) (builder.Cond, error) {
|
||||
if comparator == taskFilterComparatorEquals {
|
||||
return &builder.IsNull{field}, nil
|
||||
}
|
||||
if comparator == taskFilterComparatorNotEquals {
|
||||
return &builder.NotNull{field}, nil
|
||||
}
|
||||
return nil, ErrInvalidTimeEntryFilterValue{Field: field, Value: "null"}
|
||||
}
|
||||
|
||||
func timeEntryIntFilterValue(raw string, comparator taskFilterComparator) (any, error) {
|
||||
if comparator == taskFilterComparatorIn || comparator == taskFilterComparatorNotIn {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]int64, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
v, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
}
|
||||
|
||||
// timeEntryTimeFilterValue mirrors the task filter's date handling: datemath
|
||||
// (now, now-7d) first, then explicit date formats.
|
||||
func timeEntryTimeFilterValue(raw string, loc *time.Location) (time.Time, error) {
|
||||
if loc == nil {
|
||||
loc = config.GetTimeZone()
|
||||
}
|
||||
if expr, err := safeDatemathParse(raw); err == nil {
|
||||
t := expr.Time(datemath.WithLocation(loc)).In(config.GetTimeZone())
|
||||
return adjustDateForMysql(t), nil
|
||||
}
|
||||
return parseTimeFromUserInput(raw, loc)
|
||||
}
|
||||
|
|
@ -0,0 +1,729 @@
|
|||
// 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 models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"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"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func timePtr(t time.Time) *time.Time { return &t }
|
||||
|
||||
// Fixture access graph (pkg/db/fixtures): project 1 is owned by user1 only
|
||||
// (everyone else a stranger); task 1 lives in project 1. Project 3 is owned by
|
||||
// user3, with user1 and user2 granted read. user4 has access to neither.
|
||||
// Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3.
|
||||
|
||||
func TestTimeEntry_CanRead(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entryID int64
|
||||
auth web.Auth
|
||||
wantCan bool
|
||||
wantErr func(error) bool
|
||||
}{
|
||||
{"owner reads task entry", 1, &user.User{ID: 1}, true, nil},
|
||||
{"owner reads project entry", 2, &user.User{ID: 1}, true, nil},
|
||||
{"reader reads other user's entry on a shared project", 3, &user.User{ID: 1}, true, nil},
|
||||
{"stranger denied on owned project", 1, &user.User{ID: 4}, false, nil},
|
||||
{"stranger denied on shared project", 3, &user.User{ID: 4}, false, nil},
|
||||
{"link share denied", 1, &LinkSharing{ID: 1, ProjectID: 1}, false, nil},
|
||||
{"missing entry is a 404", 999, &user.User{ID: 1}, false, IsErrTimeEntryDoesNotExist},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
can, _, err := (&TimeEntry{ID: tt.entryID}).CanRead(s, tt.auth)
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.True(t, tt.wantErr(err), "unexpected error type: %v", err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCan, can)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeEntry_CanCreate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry *TimeEntry
|
||||
auth web.Auth
|
||||
wantCan bool
|
||||
wantErr func(error) bool
|
||||
}{
|
||||
{"on a task in an owned project", &TimeEntry{TaskID: 1}, &user.User{ID: 1}, true, nil},
|
||||
{"on an owned project", &TimeEntry{ProjectID: 1}, &user.User{ID: 1}, true, nil},
|
||||
{"on a readable project", &TimeEntry{ProjectID: 3}, &user.User{ID: 1}, true, nil},
|
||||
{"stranger denied", &TimeEntry{ProjectID: 1}, &user.User{ID: 4}, false, nil},
|
||||
{"both task and project is invalid", &TimeEntry{TaskID: 1, ProjectID: 1}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer},
|
||||
{"neither task nor project is invalid", &TimeEntry{}, &user.User{ID: 1}, false, IsErrTimeEntryInvalidContainer},
|
||||
{"link share denied", &TimeEntry{ProjectID: 1}, &LinkSharing{ID: 1, ProjectID: 1}, false, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
can, err := tt.entry.CanCreate(s, tt.auth)
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.True(t, tt.wantErr(err), "unexpected error type: %v", err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCan, can)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Entry 3 is authored by user3; user1 can read project 3 but is not the author,
|
||||
// so it can read but not modify.
|
||||
func TestTimeEntry_CanModify(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entryID int64
|
||||
auth web.Auth
|
||||
wantCan bool
|
||||
}{
|
||||
{"author modifies own entry", 1, &user.User{ID: 1}, true},
|
||||
{"author modifies own entry on shared project", 3, &user.User{ID: 3}, true},
|
||||
{"reader who is not author cannot modify", 3, &user.User{ID: 1}, false},
|
||||
{"stranger cannot modify", 3, &user.User{ID: 4}, false},
|
||||
{"link share cannot modify", 1, &LinkSharing{ID: 1, ProjectID: 1}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
canUpdate, err := (&TimeEntry{ID: tt.entryID}).CanUpdate(s, tt.auth)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCan, canUpdate, "CanUpdate")
|
||||
|
||||
canDelete, err := (&TimeEntry{ID: tt.entryID}).CanDelete(s, tt.auth)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCan, canDelete, "CanDelete")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the data leak: ReadAll must return only entries on tasks/projects the
|
||||
// caller can read, since DoReadAll runs no permission check.
|
||||
func TestTimeEntry_ReadAll(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
auth web.Auth
|
||||
wantIDs []int64
|
||||
}{
|
||||
{"user sees every readable entry", &user.User{ID: 1}, []int64{1, 2, 3, 4}},
|
||||
{"user sees only entries on projects they can read", &user.User{ID: 2}, []int64{3}},
|
||||
{"stranger sees nothing", &user.User{ID: 4}, []int64{}},
|
||||
{"link share sees nothing", &LinkSharing{ID: 1, ProjectID: 1}, []int64{}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
result, count, total, err := (&TimeEntry{}).ReadAll(s, tt.auth, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
entries, ok := result.([]*TimeEntry)
|
||||
require.True(t, ok)
|
||||
|
||||
gotIDs := make([]int64, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
gotIDs = append(gotIDs, e.ID)
|
||||
}
|
||||
assert.ElementsMatch(t, tt.wantIDs, gotIDs)
|
||||
assert.Equal(t, len(tt.wantIDs), count)
|
||||
assert.Equal(t, int64(len(tt.wantIDs)), total)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering reuses the task filter grammar. user1 can read entries 1,2,4
|
||||
// (project 1) and 3 (project 3, shared) — the filter only narrows that set.
|
||||
func TestTimeEntry_ReadAll_Filter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
wantIDs []int64
|
||||
wantErr bool
|
||||
}{
|
||||
{"by user", "user_id = 3", []int64{3}, false},
|
||||
{"by task", "task_id = 1", []int64{1, 4}, false},
|
||||
{"by project unions task-attached entries", "project_id = 1", []int64{1, 2, 4}, false},
|
||||
{"by project negated", "project_id != 1", []int64{3}, false},
|
||||
{"by start time", "start_time > '2018-12-01T11:00:00+00:00'", []int64{2, 3, 4}, false},
|
||||
{"running timers via null end_time", "end_time = null", []int64{4}, false},
|
||||
{"compound and", "user_id = 1 && end_time = null", []int64{4}, false},
|
||||
{"compound or", "user_id = 3 || task_id = 1", []int64{1, 3, 4}, false},
|
||||
{"in list", "user_id in 1,3", []int64{1, 2, 3, 4}, false},
|
||||
{"comment is not filterable", "comment = whatever", nil, true},
|
||||
{"unknown field errors", "bogus = 1", nil, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{Filter: tt.filter}
|
||||
result, _, _, err := te.ReadAll(s, &user.User{ID: 1}, "", 1, 50)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
entries, ok := result.([]*TimeEntry)
|
||||
require.True(t, ok)
|
||||
gotIDs := make([]int64, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
gotIDs = append(gotIDs, e.ID)
|
||||
}
|
||||
assert.ElementsMatch(t, tt.wantIDs, gotIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Search matches the entry comment. Comments: 1="Time entry on task 1",
|
||||
// 2/3 contain "Standalone", 4="Running timer".
|
||||
func TestTimeEntry_ReadAll_Search(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
search string
|
||||
wantIDs []int64
|
||||
}{
|
||||
{"matches a comment", "Running", []int64{4}},
|
||||
{"is case-insensitive", "running", []int64{4}},
|
||||
{"matches several", "Standalone", []int64{2, 3}},
|
||||
{"no match", "nothing matches this", []int64{}},
|
||||
{"empty search returns all readable", "", []int64{1, 2, 3, 4}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
result, _, _, err := (&TimeEntry{}).ReadAll(s, &user.User{ID: 1}, tt.search, 1, 50)
|
||||
require.NoError(t, err)
|
||||
entries, ok := result.([]*TimeEntry)
|
||||
require.True(t, ok)
|
||||
gotIDs := make([]int64, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
gotIDs = append(gotIDs, e.ID)
|
||||
}
|
||||
assert.ElementsMatch(t, tt.wantIDs, gotIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeEntry_Create(t *testing.T) {
|
||||
t.Run("manual entry keeps its start time and is owned by the caller", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
start := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
end := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: &end, Comment: "work"}
|
||||
require.NoError(t, te.Create(s, &user.User{ID: 1}))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
assert.Equal(t, int64(1), te.UserID)
|
||||
assert.True(t, te.StartTime.Equal(start))
|
||||
db.AssertExists(t, "time_entries", map[string]interface{}{
|
||||
"id": te.ID,
|
||||
"user_id": 1,
|
||||
"task_id": 1,
|
||||
"comment": "work",
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("defaults the start time to now when none is given", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1}
|
||||
require.NoError(t, te.Create(s, &user.User{ID: 1}))
|
||||
assert.False(t, te.StartTime.IsZero())
|
||||
})
|
||||
|
||||
t.Run("a completed manual entry leaves a running timer alone", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// entry 4 is user1's running timer
|
||||
manual := &TimeEntry{
|
||||
TaskID: 1,
|
||||
StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC),
|
||||
EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)),
|
||||
}
|
||||
require.NoError(t, manual.Create(s, &user.User{ID: 1}))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
running := &TimeEntry{}
|
||||
exists, err := s.Where("id = ?", 4).Get(running)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.Nil(t, running.EndTime, "a manual entry must not stop the running timer")
|
||||
})
|
||||
|
||||
t.Run("auto-stops the caller's running timer", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
a := &user.User{ID: 1}
|
||||
|
||||
first := &TimeEntry{TaskID: 1}
|
||||
require.NoError(t, first.Create(s, a))
|
||||
require.Nil(t, first.EndTime, "first timer should be running")
|
||||
|
||||
second := &TimeEntry{TaskID: 1}
|
||||
require.NoError(t, second.Create(s, a))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
reloaded := &TimeEntry{}
|
||||
exists, err := s.Where("id = ?", first.ID).Get(reloaded)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.NotNil(t, reloaded.EndTime, "first timer should have been auto-stopped")
|
||||
assert.Nil(t, second.EndTime, "second timer should still be running")
|
||||
})
|
||||
}
|
||||
|
||||
// A running timer (no end) must round-trip as a NULL end_time: found by the
|
||||
// null filter and serialized as JSON null, never the 0001-01-01 zero sentinel.
|
||||
func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
a := &user.User{ID: 1}
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)}
|
||||
require.NoError(t, te.Create(s, a))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
reloaded, err := getTimeEntryByID(s, te.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
marshalled, err := json.Marshal(reloaded)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(marshalled), `"end_time":null`)
|
||||
assert.NotContains(t, string(marshalled), "0001-01-01")
|
||||
|
||||
// Stored as NULL, so the null filter matches it (not just the fixtures).
|
||||
found := &TimeEntry{Filter: "end_time = null"}
|
||||
result, _, _, err := found.ReadAll(s, a, "", 1, 50)
|
||||
require.NoError(t, err)
|
||||
ids := []int64{}
|
||||
for _, e := range result.([]*TimeEntry) {
|
||||
ids = append(ids, e.ID)
|
||||
}
|
||||
assert.Contains(t, ids, te.ID)
|
||||
}
|
||||
|
||||
// Regression guard: the permission check must not clobber the update payload.
|
||||
func TestTimeEntry_Update(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
a := &user.User{ID: 1}
|
||||
|
||||
te := &TimeEntry{
|
||||
ID: 1,
|
||||
TaskID: 1,
|
||||
StartTime: time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC),
|
||||
EndTime: timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)),
|
||||
Comment: "updated comment",
|
||||
}
|
||||
|
||||
can, err := te.CanUpdate(s, a) // the handler calls this before Update
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, te.Update(s, a))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
assert.Equal(t, "updated comment", te.Comment)
|
||||
db.AssertExists(t, "time_entries", map[string]interface{}{
|
||||
"id": 1,
|
||||
"comment": "updated comment",
|
||||
}, false)
|
||||
}
|
||||
|
||||
func TestTimeEntry_UpdateReassignsContainer(t *testing.T) {
|
||||
validTimes := func(te *TimeEntry) {
|
||||
te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
t.Run("moves an entry from a task to a project", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
a := &user.User{ID: 1}
|
||||
|
||||
// Entry 1 is on task 1; move it onto project 1 directly.
|
||||
te := &TimeEntry{ID: 1, ProjectID: 1}
|
||||
validTimes(te)
|
||||
|
||||
can, err := te.CanUpdate(s, a)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, te.Update(s, a))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
db.AssertExists(t, "time_entries", map[string]interface{}{
|
||||
"id": 1,
|
||||
"task_id": 0,
|
||||
"project_id": 1,
|
||||
}, false)
|
||||
})
|
||||
|
||||
t.Run("rejects an update that sets both task and project", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
_, err := (&TimeEntry{ID: 1, TaskID: 1, ProjectID: 1}).CanUpdate(s, &user.User{ID: 1})
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTimeEntryInvalidContainer(err))
|
||||
})
|
||||
|
||||
t.Run("an omitted container keeps the existing one", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
a := &user.User{ID: 1}
|
||||
|
||||
// Entry 1 is on task 1; update only the comment, no container set.
|
||||
te := &TimeEntry{ID: 1, Comment: "kept on task"}
|
||||
validTimes(te)
|
||||
|
||||
can, err := te.CanUpdate(s, a)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, te.Update(s, a))
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
db.AssertExists(t, "time_entries", map[string]interface{}{
|
||||
"id": 1,
|
||||
"task_id": 1,
|
||||
"project_id": 0,
|
||||
"comment": "kept on task",
|
||||
}, false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeEntry_UpdateReopenGuard(t *testing.T) {
|
||||
a := &user.User{ID: 1}
|
||||
someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("rejects clearing the end of a completed entry", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Entry 1 is completed; a nil end would reopen it as a running timer.
|
||||
te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart} // EndTime nil
|
||||
can, err := te.CanUpdate(s, a)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
|
||||
err = te.Update(s, a)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTimeEntryAlreadyEnded(err), "unexpected error type: %v", err)
|
||||
})
|
||||
|
||||
t.Run("allows editing a running entry while it stays running", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Entry 4 is user1's running timer; keeping it running (nil end) is fine.
|
||||
te := &TimeEntry{ID: 4, TaskID: 1, StartTime: someStart, Comment: "edited"} // EndTime nil
|
||||
can, err := te.CanUpdate(s, a)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, te.Update(s, a))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeEntry_RejectsInvertedInterval(t *testing.T) {
|
||||
a := &user.User{ID: 1}
|
||||
start := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
before := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("create rejects an end before the start", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(before)}
|
||||
err := te.Create(s, a)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err)
|
||||
})
|
||||
|
||||
t.Run("create allows an end equal to the start", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: start, EndTime: timePtr(start)}
|
||||
require.NoError(t, te.Create(s, a))
|
||||
})
|
||||
|
||||
t.Run("create allows a running timer with no end", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: start} // EndTime nil
|
||||
require.NoError(t, te.Create(s, a))
|
||||
})
|
||||
|
||||
t.Run("update rejects an end before the start", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Entry 1 is user1's completed entry.
|
||||
te := &TimeEntry{ID: 1, TaskID: 1, StartTime: start, EndTime: timePtr(before)}
|
||||
can, err := te.CanUpdate(s, a)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
|
||||
err = te.Update(s, a)
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrTimeEntryEndBeforeStart(err), "unexpected error type: %v", err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeEntry_StopRunningTimer(t *testing.T) {
|
||||
t.Run("stops the caller's running timer and returns it", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
entry, err := StopRunningTimer(s, &user.User{ID: 1}) // entry 4
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
|
||||
assert.Equal(t, int64(4), entry.ID)
|
||||
assert.NotNil(t, entry.EndTime)
|
||||
|
||||
reloaded := &TimeEntry{}
|
||||
_, err = s.Where("id = ?", 4).Get(reloaded)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, reloaded.EndTime, "end time should be persisted")
|
||||
})
|
||||
|
||||
t.Run("errors when no timer is running", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
_, err := StopRunningTimer(s, &user.User{ID: 2}) // user2 has no entries
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrNoRunningTimer(err), "unexpected error type: %v", err)
|
||||
})
|
||||
|
||||
t.Run("denies a link share and leaves the matching user's timer running", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// Share id 1 collides with user 1, whose entry 4 is a running timer.
|
||||
_, err := StopRunningTimer(s, &LinkSharing{ID: 1, ProjectID: 1})
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsErrGenericForbidden(err), "unexpected error type: %v", err)
|
||||
|
||||
running := &TimeEntry{}
|
||||
exists, err := s.Where("id = ?", 4).Get(running)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.Nil(t, running.EndTime, "the user's timer must not have been stopped by a link share")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeEntry_Events(t *testing.T) {
|
||||
u := &user.User{ID: 1}
|
||||
someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
t.Run("create dispatches created", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
|
||||
require.NoError(t, te.Create(s, u))
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
|
||||
})
|
||||
|
||||
t.Run("update dispatches updated", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{ID: 1, TaskID: 1, StartTime: someStart, EndTime: someEnd, Comment: "edited"}
|
||||
can, err := te.CanUpdate(s, u)
|
||||
require.NoError(t, err)
|
||||
require.True(t, can)
|
||||
require.NoError(t, te.Update(s, u))
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
|
||||
})
|
||||
|
||||
t.Run("delete dispatches deleted", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, u))
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
events.AssertDispatched(t, &TimeEntryDeletedEvent{})
|
||||
})
|
||||
|
||||
t.Run("starting a timer dispatches created plus updated for the auto-stopped entry", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
// entry 4 is user1's running timer; a new running timer auto-stops it
|
||||
require.NoError(t, (&TimeEntry{TaskID: 1}).Create(s, u))
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
events.AssertDispatched(t, &TimeEntryCreatedEvent{})
|
||||
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
|
||||
})
|
||||
|
||||
t.Run("a completed manual entry dispatches only created", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
te := &TimeEntry{TaskID: 1, StartTime: someStart, EndTime: someEnd}
|
||||
require.NoError(t, te.Create(s, u))
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
assert.Equal(t, 1, events.CountDispatchedEvents((&TimeEntryCreatedEvent{}).Name()))
|
||||
assert.Equal(t, 0, events.CountDispatchedEvents((&TimeEntryUpdatedEvent{}).Name()), "a completed manual entry must not auto-stop")
|
||||
})
|
||||
|
||||
t.Run("StopRunningTimer dispatches updated", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
events.ClearDispatchedEvents()
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
_, err := StopRunningTimer(s, u)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Commit())
|
||||
events.DispatchPending(s)
|
||||
events.AssertDispatched(t, &TimeEntryUpdatedEvent{})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTimeEntry_Delete(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, (&TimeEntry{ID: 1}).Delete(s, &user.User{ID: 1}))
|
||||
require.NoError(t, s.Commit())
|
||||
db.AssertMissing(t, "time_entries", map[string]interface{}{"id": 1})
|
||||
}
|
||||
|
||||
func TestTimeEntry_TaskCount(t *testing.T) {
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
t.Run("attaches counts for a licensed, non-share caller", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
task1 := &Task{ID: 1} // fixtures: time entries 1 and 4 are attached to task 1
|
||||
task2 := &Task{ID: 2} // no time entries
|
||||
taskMap := map[int64]*Task{1: task1, 2: task2}
|
||||
|
||||
require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1, 2}, taskMap))
|
||||
|
||||
require.NotNil(t, task1.TimeEntriesCount)
|
||||
assert.Equal(t, int64(2), *task1.TimeEntriesCount)
|
||||
require.NotNil(t, task2.TimeEntriesCount)
|
||||
assert.Equal(t, int64(0), *task2.TimeEntriesCount)
|
||||
})
|
||||
|
||||
t.Run("leaves the count unset for a link share", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
task1 := &Task{ID: 1}
|
||||
taskMap := map[int64]*Task{1: task1}
|
||||
require.NoError(t, addTimeEntriesCountToTasks(s, &LinkSharing{ID: 1}, []int64{1}, taskMap))
|
||||
assert.Nil(t, task1.TimeEntriesCount, "link shares must not learn time-entry counts")
|
||||
})
|
||||
|
||||
t.Run("leaves the count unset when the feature is unlicensed", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.ResetForTests() // feature disabled
|
||||
|
||||
task1 := &Task{ID: 1}
|
||||
taskMap := map[int64]*Task{1: task1}
|
||||
require.NoError(t, addTimeEntriesCountToTasks(s, u, []int64{1}, taskMap))
|
||||
assert.Nil(t, task1.TimeEntriesCount, "an unlicensed instance must not expose counts")
|
||||
})
|
||||
}
|
||||
|
|
@ -47,29 +47,29 @@ var webhookClient *http.Client
|
|||
|
||||
type Webhook struct {
|
||||
// The generated ID of this webhook target
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook"`
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"webhook" readOnly:"true" doc:"The generated ID of this webhook target."`
|
||||
// The target URL where the POST request with the webhook payload will be made
|
||||
TargetURL string `xorm:"not null" valid:"required,url" json:"target_url"`
|
||||
TargetURL string `xorm:"not null" valid:"required,url" json:"target_url" doc:"The target URL where the POST request with the webhook payload will be made."`
|
||||
// The webhook events which should fire this webhook target
|
||||
Events []string `xorm:"JSON not null" valid:"required" json:"events"`
|
||||
Events []string `xorm:"JSON not null" valid:"required" json:"events" doc:"The webhook events which should fire this webhook target. Get the available events from /api/v1/webhooks/events."`
|
||||
// The project ID of the project this webhook target belongs to
|
||||
ProjectID int64 `xorm:"bigint null index" json:"project_id" param:"project"`
|
||||
ProjectID int64 `xorm:"bigint null index" json:"project_id" param:"project" readOnly:"true" doc:"The id of the project this webhook target belongs to. Set from the URL, not the body."`
|
||||
// The user ID if this is a user-level webhook (mutually exclusive with ProjectID)
|
||||
UserID int64 `xorm:"bigint null index" json:"user_id"`
|
||||
UserID int64 `xorm:"bigint null index" json:"user_id" readOnly:"true" doc:"The id of the user if this is a user-level webhook (mutually exclusive with project_id)."`
|
||||
// If provided, webhook requests will be signed using HMAC. Check out the docs about how to use this: https://vikunja.io/docs/webhooks/#signing
|
||||
Secret string `xorm:"null" json:"secret"`
|
||||
Secret string `xorm:"null" json:"secret" writeOnly:"true" doc:"If provided, webhook requests will be signed using HMAC. See https://vikunja.io/docs/webhooks/#signing. Write-only: never returned in responses."`
|
||||
// If provided, webhook requests will be sent with a Basic Auth header.
|
||||
BasicAuthUser string `xorm:"null" json:"basic_auth_user"`
|
||||
BasicAuthPassword string `xorm:"null" json:"basic_auth_password"`
|
||||
BasicAuthUser string `xorm:"null" json:"basic_auth_user" writeOnly:"true" doc:"If provided together with basic_auth_password, webhook requests will be sent with a Basic Auth header. Write-only: never returned in responses."`
|
||||
BasicAuthPassword string `xorm:"null" json:"basic_auth_password" writeOnly:"true" doc:"The password for the Basic Auth header. Write-only: never returned in responses."`
|
||||
|
||||
// The user who initially created the webhook target.
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"`
|
||||
CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-" readOnly:"true" doc:"The user who initially created the webhook target."`
|
||||
CreatedByID int64 `xorm:"bigint not null" json:"-"`
|
||||
|
||||
// A timestamp when this webhook target was created. You cannot change this value.
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this webhook target was created. You cannot change this value."`
|
||||
// A timestamp when this webhook target was last updated. You cannot change this value.
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this webhook target was last updated. You cannot change this value."`
|
||||
|
||||
web.CRUDable `xorm:"-" json:"-"`
|
||||
web.Permissions `xorm:"-" json:"-"`
|
||||
|
|
@ -79,6 +79,17 @@ func (w *Webhook) TableName() string {
|
|||
return "webhooks"
|
||||
}
|
||||
|
||||
// maskCredentials clears the write-only secret and basic-auth fields so they are
|
||||
// never echoed back in a response. The client already submitted these values and
|
||||
// the DB row keeps them (outgoing deliveries reload and sign from the DB copy);
|
||||
// only the in-memory struct returned to the caller is cleared. Always call this
|
||||
// after the DB write, never before.
|
||||
func (w *Webhook) maskCredentials() {
|
||||
w.Secret = ""
|
||||
w.BasicAuthUser = ""
|
||||
w.BasicAuthPassword = ""
|
||||
}
|
||||
|
||||
var availableWebhookEvents map[string]bool
|
||||
var availableWebhookEventsLock *sync.Mutex
|
||||
var userDirectedWebhookEvents map[string]bool
|
||||
|
|
@ -183,6 +194,11 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) {
|
|||
}
|
||||
|
||||
w.CreatedBy, err = user.GetUserByID(s, a.GetID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.maskCredentials()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -234,9 +250,7 @@ func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPa
|
|||
}
|
||||
|
||||
for _, webhook := range ws {
|
||||
webhook.Secret = ""
|
||||
webhook.BasicAuthUser = ""
|
||||
webhook.BasicAuthPassword = ""
|
||||
webhook.maskCredentials()
|
||||
if createdBy, has := users[webhook.CreatedByID]; has {
|
||||
webhook.CreatedBy = createdBy
|
||||
}
|
||||
|
|
@ -268,6 +282,11 @@ func (w *Webhook) Update(s *xorm.Session, _ web.Auth) (err error) {
|
|||
_, err = s.Where("id = ?", w.ID).
|
||||
Cols("events").
|
||||
Update(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.maskCredentials()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -26,6 +27,7 @@ import (
|
|||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/humaecho5"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
|
||||
|
|
@ -383,3 +385,14 @@ func RefreshSession(rawRefreshToken string) (*RefreshResult, error) {
|
|||
SessionID: session.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAuthFromContext retrieves the authenticated web.Auth from a plain
|
||||
// context.Context, bridging Huma handlers to Vikunja's echo JWT flow. The
|
||||
// humaecho5 adapter stashes the *echo.Context under EchoContextKey first.
|
||||
func GetAuthFromContext(ctx context.Context) (web.Auth, error) {
|
||||
ec, ok := ctx.Value(humaecho5.EchoContextKey).(*echo.Context)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no echo.Context on request context; are you calling GetAuthFromContext from a Huma handler dispatched by humaecho5?")
|
||||
}
|
||||
return GetAuthFromClaims(ec)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue