Commit Graph

1265 Commits

Author SHA1 Message Date
kolaente
ac853537e9 fix(tasks): make subtask root condition NULL-safe and search-aware
When expanding subtasks, a task was excluded from the roots via NOT over
LEFT-JOINed parent columns. Any filter predicate evaluating to NULL on
the parent row (e.g. due_date <= X with a dateless parent) made the
whole condition NULL, silently dropping the child from the result — it
was neither a root nor nested anywhere. This hit saved filters, list
views with date filters and the gantt view by default.

Rebuild the root condition as a correlated NOT EXISTS subquery, where a
NULL predicate collapses to a clean FALSE, and mirror the free-text
search into the parent predicate so searching for a subtask by title no
longer hides it when its parent does not match the search.
2026-07-15 19:31:14 +02:00
TowyTowy
3af3c04f03 fix(filter): accept non-zero-padded dates in task filters
The manual date fallback in parseTimeFromUserInput parses dates like
2022-11-1, but `year, err :=` shadowed the named err return. On a
successful manual parse the function returned a valid time alongside the
stale error from the earlier failed layout attempts, so filters such as
`due_date = 2022-11-1` were rejected as invalid. Assign to the named
return so success clears the error.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 07:50:32 +00:00
kallegrens
82ee780161 feat(caldav): implement RFC 6578 sync-collection REPORT to sync task deletions
iOS Reminders (and other RFC 6578 clients) never learned about tasks
deleted on the Vikunja side: caldav-go answers unknown REPORT types
with 412, which makes iOS silently stop syncing, and deleted tasks left
no protocol-level trace a client could observe.

Intercept sync-collection REPORTs before caldav-go sees them and answer
per RFC 6578: tasks changed since the sync token as 200 entries and
deleted tasks as 404 entries. Deletion records come from task
soft-deletes, which are kept for 30 days before being purged.

Token edge cases: delta comparisons are inclusive because tokens have
second granularity (re-reported items keep their etag, so clients skip
the download), and tokens older than the soft-delete retention answer
403 + valid-sync-token to force a full resync instead of a delta that
would silently miss purged deletions.
2026-07-07 11:57:04 +00:00
kolaente
c85a56511b fix(tasks): bump project and task updated times when task sub-entities change
Relations, comments, attachments, assignees (via the shared listener)
and label add/remove now advance the project's updated timestamp, and
label changes also advance the task's, so CalDAV ctags and
sync-collection deltas reflect every change that is visible in a VTODO.
2026-07-07 11:57:04 +00:00
Tink
d83e90541c feat(tasks): soft-delete tasks with permanent deletion after 30 days (#3119) 2026-07-07 11:38:33 +02:00
kolaente
8a79c30477 chore(search): consolidate scoring comments and deduplicate test helpers
The ranking-gate comment still claimed title and description weigh
equally, which the 1.5x boost made wrong; scoring semantics now live only
at the boost call site. Test subtests share searchTaskPositions instead
of hand-building position maps four times.
2026-07-06 15:45:55 +00:00
kolaente
0986782321 feat(search): boost title matches 1.5x over description matches
With constant-score relevance, 1.5 ranks a title match above a description
match while two matched words (2.0) still beat a single boosted title word,
preserving "more matched words wins". ParadeDB-only; the substring fallback
has no scoring.
2026-07-06 15:45:55 +00:00
kolaente
f980f0424f docs(search): describe the actual constant-score relevance semantics
The fuzzy query shape makes pdb.score a constant-score sum (1.0 per
exact/prefix word-field match, 0.5 per one-edit match), not BM25, and no
field boosting is configured. Correct the comments claiming otherwise.
2026-07-06 15:45:55 +00:00
kolaente
350704573e test(search): pin ParadeDB matching contract with a behavioral test suite
Covers per-token matching across title and description, word-order
independence, description-only matches, and fuzzy(1)-against-token-prefix
matching, including its surprising looseness ("two" matching "world" via
the one-edit prefix "wo"). Skipped on databases without ParadeDB, whose
whole-string substring fallback intentionally matches less.
2026-07-06 15:45:55 +00:00
kolaente
167556ce2b feat(search): add explicit relevance sort field
sort_by=relevance renders as pdb.score(tasks.id) DESC at its requested
position, so clients can express orderings like done,relevance (undone
first, most relevant within each group). The implicit no-sort ranking
reuses the same mechanism via an injected sort param.

When the database or query shape cannot score, relevance params are
dropped and the remaining sorts plus the id tiebreaker apply, so the
field is safe to send unconditionally; getOrderByDBStatement also only
emits pdb.score when ParadeDB is actually available.

The quick-actions search sends sort_by=done,relevance (typed via
TaskFilterParams) and keeps surfacing undone tasks first.
2026-07-06 15:45:55 +00:00
kolaente
e24ec07293 feat(search): rank ParadeDB search results by relevance
Searching with ParadeDB returns fuzzy/OR matches in id order, so a task
matching all query words can sink below tasks matching only one. When a
search term is present and the client sends no explicit sort, order the
results by pdb.score(tasks.id), keeping the stable id tiebreak.

Numeric #id searches and the Favorites view keep the default order since
pdb.score rejects those query shapes. On the all-projects scope the
favorites arm is dropped when every favorited task already lies inside
the project scope, so global search stays ranked for users with
favorites; out-of-scope favorites fall back to the unranked order.

The frontend omits sort_by while searching in project views unless the
user explicitly picked a sort, so the backend ranking engages.
2026-07-06 15:45:55 +00:00
nithin varma
afdc471c24 fix(task): recurring tasks return to original bucket when no default is set (#2806) 2026-07-03 23:18:00 +02:00
kolaente
cc627ee634 fix(tasks): prevent position race conditions with atomic upserts and locking (#3098) 2026-07-03 19:58:39 +02:00
Tink
0e3171ebbb fix(tasks): show filter-matched subtasks whose parent is filtered out (#2952) 2026-07-03 19:38:01 +02:00
kolaente
3a666da860 feat: audit admin reads and denied admin access
GET /admin/users returns every user's email address; compliance
regimes commonly require admin PII access to be logged, so the list
read now emits admin.users.listed. The admin gate additionally emits
admin.access.denied (outcome=failure, with method and path) whenever
an authenticated user without the instance-admin flag probes an
/admin/* route — dispatched directly with the request context since
there is no transaction.

Both entries have no single affected resource, so the entry target is
now omitted when empty instead of serialising a zero value.
2026-07-03 16:56:03 +00:00
kolaente
6f21c57c78 feat: audit admin user and project mutations
Every mutating admin action now dispatches a typed event on commit
(admin.user.created, admin.user.admin.granted/revoked,
admin.user.status.changed, admin.user.password.set,
admin.user.password_reset.sent, admin.user.deleted,
admin.project.owner.changed) which the audit catalog maps onto an
entry with the acting admin as actor and the affected user or project
as target. The model actions grow a doer parameter since the acting
admin was not threaded into the models layer before.

Admin creation deliberately emits both user.created (actor = the new
user, unchanged semantics) and admin.user.created (actor = the admin).
The events are instance-level, not project-scoped, so they are not
exposed as webhook events. No password or reset-token material is ever
part of a payload.
2026-07-03 16:56:03 +00:00
Bradley Erickson
61d74ee2ef fix: assign bot-created projects to the bot's owner
When a bot user creates a project, the bot's owner (the human who
created the bot) now owns the project instead of the bot itself. The bot
retains admin access via an explicit project share. This ensures the
human can always see and manage projects their bots create.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-03 06:53:29 +00:00
Tink
f11c90ca6b fix: create saved filter positions before the fetch query (#3084) 2026-07-02 19:43:15 +02:00
Tink
d0dccf2736 feat(pro): admin password reset for existing users (#3085) 2026-07-02 19:39:00 +02:00
Bradley Erickson
bdb07799d3 fix(api): return 200 instead of 500 when listing attachments on a task with none
ReadAll used a bare return when len(attachments) == 0, which returned
nil for the interface{} result. The v2 handler's type assertion then
failed on nil, producing an untyped error that Huma surfaced as 500.

Return the empty slice explicitly so the assertion succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-01 12:42:32 +00:00
kolaente
93f88084de fix: resolve golangci-lint v2.12 findings 2026-06-30 18:53:56 +00:00
kolaente
e1afa039cb fix: use correct type for permission error 2026-06-29 17:18:05 +02:00
kolaente
9e880e98a5 fix(api): export api-token permission groups in snake_case
The api-token permission group key is derived from the route slug. Every
group is snake_case except "time-entries", whose URL slug carries a hyphen.
The frontend snake_cases request payloads, rewriting that group key to
"time_entries", which the backend then rejected — so a token granted the
Time Entries scope could not be saved.

Canonicalise group and path-segment names to snake_case where they are
derived, and normalise the group key on token validation and authorisation
so any token stored under the old hyphenated key keeps resolving. No data
migration is needed: the v2 time-entries resource has never shipped in a
release.
2026-06-27 15:01:54 +00:00
gabe
98b3613247 feat(filters): generate open task saved filter on user creation 2026-06-27 13:35:50 +00:00
Bradley Erickson
f8eacca7c8 fix(auth): allow api tokens to access global v2 task list endpoint
The tasks.read_all special case in CanDoAPIRoute only covered v1 paths.
Both GET /api/v2/tasks and GET /api/v2/projects/:project/tasks normalize
to the same tasks.read_all map key, but only one RouteDetail survives —
the project-scoped path overwrites the global one. The exact path
comparison then rejects the global endpoint with 401.

Extend the special case to include the v2 paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 17:49:02 +00:00
Tink
7208694960 fix(auth): build OIDC end-session URL with RP-Initiated Logout params (#2943) 2026-06-19 18:27:33 +02:00
Tink
767ce3bc7e fix(tasks): reset description checklist when a recurring task recurs (#2941) 2026-06-19 16:54:20 +02:00
Tink
f3c6312a9e feat(projects): make duplicating shares opt-in (#2932) 2026-06-19 10:15:58 +02:00
kolaente
bf175dde6d fix(kanban): upsert race condition in kanban task bucket sync (#2938) 2026-06-19 10:09:00 +02:00
kolaente
5236e0c306 fix(notifications): use full user so notifications show display name
Notifications and emails showed the acting user's auto-generated
username instead of their display Name.

The doer attached to notification events was built straight from the
JWT via user.GetFromAuth, which only carries id + username (Name is
never set in GetUserFromClaims). Notifications render n.Doer.GetName(),
which falls back to the username when Name is empty, so every "assigned
you", "mentioned you", task-deleted, project-created and team-member
notification rendered the username.

Resolve the full user from the database at the event-producing dispatch
sites. doerFromAuth now re-fetches the user (with Name) and is reused by
all the notification doers; account-status errors are swallowed so flows
acting on behalf of disabled accounts (e.g. user deletion deleting that
user's tasks) keep working while still carrying the display name.

Fixes #2720
2026-06-18 20:57:05 +00:00
kolaente
37a34cc5cf fix(notifications): log unexpected user refresh failures
A transient database error while reloading a notification's user was
swallowed silently, leaving stale names with no trace. Log everything
except the expected "user was deleted" case.
2026-06-17 21:18:04 +00:00
kolaente
aac4dd845e refactor(notifications): refresh users via an explicit type switch
Reflection over reflect.Kind was overkill: only top-level doer/assignee/
member fields are ever rendered, and the walk forced an exhaustive linter
exclusion. List the user fields per notification type instead, which drops
the reflect dependency and the .golangci.yml carve-out.
2026-06-17 21:18:04 +00:00
kolaente
7f53be4105 fix(notifications): refresh embedded users when reading notifications
Notifications stored before the acting user was resolved with its full
profile (#2720) were serialized with only id+username, so they kept
rendering the auto-generated username instead of the display name.

Reload every embedded user from the database when reading a user's
notifications, healing already-stored rows at read time. The refresh is
not persisted; a per-page cache fetches each user once.
2026-06-17 21:18:04 +00:00
kolaente
7b7c850dd8 refactor(tasks): drop in-memory task dedup, rely on unique index
The duplicate task rows getTasksForProjects deduplicated came from the
LEFT JOIN multiplying when duplicate task_positions rows existed. The new
unique index on (task_id, project_view_id) removes the root cause at the
SQL layer (the migration also runs before serving), so the join can no
longer multiply. Revert getTasksForProjects and getRawTasksForProjects to
their pre-dedup shape.
2026-06-17 21:16:41 +00:00
kolaente
99d025399c perf(tasks): batch task position existence check into one query
filterNewTaskPositions ran one Exist query per position. createTask
calls it in loops (bulk import, project duplication), so this was
O(tasks * views) queries. Fetch all existing rows for the involved
tasks once and filter in memory instead.
2026-06-17 21:16:41 +00:00
kolaente
a61e594952 fix(tasks): prevent duplicate task_positions rows and stale identifiers
A task could end up with more than one task_positions row for the same
(task_id, project_view_id): rapid/concurrent creation raced the
check-then-insert paths, and the create path could insert a position that
a triggered RecalculateTaskPositions had already persisted for the new
task. The table had no unique constraint, so the duplicates were stored
silently (#2844).

In the table view this made the LEFT JOIN on task_positions emit the task
twice; getTasksForProjects enriched only the map entry, so the duplicate
slice row kept an empty identifier and rendered as "#N" instead of
"PREFIX-N" (#2725).

- Add a unique index on task_positions(task_id, project_view_id) via a
  dedup migration (mirrors the task_buckets fix in 20250624092830) plus the
  unique(task_view) struct tag so fresh installs get it too.
- Harden the create path: only queue a position insert when one does not
  already exist for the task+view, and dedupe within the batch.
- Dedupe the task slice returned by getTasksForProjects by id, returning
  the enriched entry, so duplicate position rows can never surface a task
  twice or with a missing identifier.

Fixes #2844
Fixes #2725
2026-06-17 21:16:41 +00:00
kolaente
55ca06ca3d fix(export): treat a missing export meta row as no export in the status
GetUserDataExportStatus propagated the raw LoadFileMetaByID error when the
meta row was gone, so /user/export could 500. The download path already
maps that case to ErrUserDataExportDoesNotExist (404); make status
consistent by returning nil (no export), matching the documented contract.
2026-06-17 18:39:38 +00:00
kolaente
8c72e83a4d feat(api/v2): add user data export endpoints
Port POST /user/export/request, POST /user/export/download (zip stream) and
GET /user/export (status) to v2. Extract the export-file loader and status
builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with
a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2
download streams via the shared WriteFileDownload writer; local users confirm
with their password, external-provider users are passed through.
2026-06-17 18:39:38 +00:00
kolaente
cf456fb223 fix(kanban): count tasks in bucket, not filter total, for saved-filter bucket limits
On a saved-filter (or view-filter) kanban view, checkBucketLimit counted
the total number of tasks matching the filter instead of the number of
tasks actually in the target bucket. Adding the first task to an empty
limited bucket was therefore wrongly rejected with code 10004
"exceeded the limit", even though the bucket was at 0/limit. The same
setup on a regular project bucket worked because that branch counts
task_buckets rows scoped to the bucket.

Scope the count to the bucket by adding `bucket_id = <id>` to the
TaskCollection filter. ReadAll combines this with the saved-filter /
view filter, so the count reflects exactly the tasks that are in this
bucket and match the filter. This keeps the #355 behaviour (stale
task_buckets rows whose tasks no longer match the filter are excluded)
while fixing the unscoped over-count.

Fixes #2672
2026-06-17 18:20:25 +00:00
kolaente
8bec654595 refactor(background): extract download + unsplash-proxy logic for reuse
Split the HTTP plumbing from the business logic in the v1 project-background
download and Unsplash image proxy handlers so /api/v2 can reuse it without
duplicating it:

- LoadProjectBackgroundForDownload (background/handler) loads the bg file +
  modtime and fires the Unsplash pingback; GetProjectBackground now calls it.
- WriteProjectBackground (web/files) writes v1's exact background wire shape
  (image/jpg, no-cache, stat-modtime Last-Modified, If-Modified-Since 304).
- FetchUnsplashImageByID / FetchUnsplashThumbByID (background/unsplash) return
  the open upstream body for the caller to stream; the v1 proxy handlers now
  call them. A typed ErrUnsplashImageDoesNotExist maps to 404 on both APIs.
- ErrProjectHasNoBackground (models) gives the no-background case a domain
  error; v1 keeps its verbatim 404 message.

v1 responses are unchanged on the wire.
2026-06-17 11:31:50 +00:00
kolaente
0eb39fae9a fix(events): handle nil auth when building event doers
ProjectUser.Create and friends are called with a nil auth in tests;
the old interface-typed Doer just serialized as null, so a nil doer
keeps that behavior (and maps to the system actor in the audit entry).
2026-06-12 08:56:08 +00:00
kolaente
f0eff52949 fix(events): build event doers without re-fetching the user
GetUserOrLinkShareUser re-fetches the account and fails its status
check, which broke deleting a disabled user's projects (the deletion
runs with the disabled account as doer). Convert the authenticated
principal directly instead — it also matches what the events serialized
before the doer became concrete, and drops a query per event.
2026-06-12 08:56:08 +00:00
kolaente
b3bcab1f72 refactor(events): use a concrete doer on project and team events
ProjectUpdated/Deleted, ProjectSharedWith* and TeamCreated/Deleted
carried an interface-typed Doer that could not be unmarshaled, forcing
the audit registrations to decode anonymous mirror structs. Hydrate the
doer via GetUserOrLinkShareUser at the dispatch sites like the task
events already do, register the events directly and drop the untyped
audit registration path.

Webhook payloads for these events now serialize link share doers as
their pseudo-user (negative id) instead of the raw link share object,
consistent with task events.
2026-06-12 08:56:08 +00:00
kolaente
f33cde82e2 feat(audit): attribute failed logins to the originating request
Thread the request context through CheckUserCredentials so the
LoginFailedEvent carries IP, user agent and request id — without it,
failed logins were the one auth event useless for brute-force tracing.
All four callers have the request at hand.
2026-06-12 08:56:08 +00:00
kolaente
9da51f5096 refactor(events): pass context to DispatchPending directly
Every DispatchPending caller either has the request context in scope or
is genuinely request-less, so passing it as a parameter replaces the
stored-context mechanism on the pending queue and satisfies
contextcheck. Also fixes lint findings in the audit package.
2026-06-12 08:56:08 +00:00
kolaente
869bec38b5 feat(audit): register the audited event surface
One config-gated block in RegisterListeners maps every opted-in event
to its audit entry. Events with interface-typed doers are decoded via
a small doer ref that distinguishes link shares by their hash field.
2026-06-12 08:56:08 +00:00
kolaente
5f4a21a4c5 feat(events): add auth boundary events
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.

None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
2026-06-12 08:56:08 +00:00
kolaente
5807f2e7b4 refactor(user): share user-search logic between v1 and v2
Extract the duplicated user-search business logic into two helpers both API
versions call, and refactor v1's handlers onto them:
- user.SearchUsers wraps ListUsers + email obfuscation (global search)
- models.SearchUsersForProject wraps the project read check + ListUsersFromProject

Each handler keeps its own forbidden mapping (v1 echo.ErrForbidden vs v2
huma) so v1 stays byte-identical on the wire.
2026-06-11 20:07:43 +00:00
kolaente
53d1fa0735 refactor(admin): share user-mutation logic between v1 and v2
The admin set-admin-flag, set-status and delete-user operations were
implemented twice — once in the v1 echo handlers, once in the v2 Huma handlers.
Extract the load/guard/mutate logic into models.SetUserAdminFlag,
models.SetUserStatusAsAdmin and models.DeleteUserAsAdmin so both APIs call the
same code; each handler keeps only its own request binding, validation and
response shape. v1 stays byte-identical on the wire.
2026-06-11 19:32:42 +00:00
kolaente
e25f997281 refactor(admin): extract shared admin overview, user-create and user-view helpers
Move the admin overview computation and struct into models.BuildOverview /
models.Overview, the admin create-user flow into models.CreateUserAsAdmin /
models.CreateUserBody, and the admin user response view into a new
pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both
the v1 and v2 admin routes call the same code. The v1 handlers are refactored
onto these helpers and stay byte-identical on the wire.
2026-06-11 19:32:42 +00:00