Pass expand=task_counts to get each project's done and undone task
counts. The v2 expand param is now repeatable so it combines with
expand=permissions.
Team.ReadAll had no ORDER BY under LIMIT/OFFSET, so pages could overlap or
skip teams, and an exact search match could fall off the first page the
pickers fetch.
The reminder cron only ran, and only created the in-app notification,
when the mailer was enabled and the user had email reminders on. The
database notification is now always stored; the mail is skipped in
ReminderDueNotification.ToMail instead.
Fixes#4072
- Owners can manage bots in any status (disabled or locked); unowned,
missing and non-bot ids all return 404 so existence never leaks.
- PUT /api/v2/user/bots/{bot} requires status, so a partial body can no
longer re-enable a disabled bot. A bot_status validator tag limits it
to active/disabled.
- BotUser.Status is a pointer: the v1 update route keeps the current
status when the body omits it instead of unlocking the bot.
- Owner status changes dispatch bot.status.changed for the audit log.
- Regenerate the frontend client and adapt the bot update callers.
Refs #3999
The gantt view of a saved filter sends filter_include_nulls to show tasks
without dates, but the flag was dropped when merging the request filter into
the saved filter. It now applies to the request filter only, so it does not
leak into the saved filter's own conditions (e.g. pulling in unassigned tasks).
Fixes#4009
The saved filter view listener only inserted bucket rows for tasks not
yet in a view, so a task completed in its own project stayed in the
filter's default bucket. Move it into the view's done bucket when done,
and back to the default bucket when undone.
Fixes#4018
Both refresh endpoints answered a missing cookie with a code-less 401, so
clients couldn't tell it apart from a rotated-away token, which is worth one
retry. ErrNoRefreshToken gives it its own code.
Refs #4023
Label.Delete only deleted the row in `labels`. Since `label_tasks` has
no foreign key cascade and no soft-delete column, every association of
the deleted label stayed behind as an orphaned row.
CanDelete loads the whole filter into the struct. xorm turns every non-empty
field into a delete condition, so a filters JSON that does not re-serialize
byte for byte silently matches nothing while still returning success.
The v1 name leaked into the v2 OpenAPI document and everything derived from
it. Every other /tasks/... route already uses {task}. The expand-scope check
is keyed by echo path template, so its entry moves too, and a webtest now
fails if any of its keys stops naming a registered route.
Users subscribed to busy projects got every overdue task in one flat list.
Tasks the user is assigned to now appear under "Assigned to you", tasks
they merely created or follow under "Tasks you follow". A task appears
once; assignee wins over follower.
Resolves https://github.com/go-vikunja/vikunja/issues/3612
Indexes come from an atomic counter increment instead of MAX(index)+1, so
retired indexes are never reused. Client-supplied indexes are ignored;
import and duplication keep presets above the old high-water mark. Moving
a task records its old address as an alias; the task row is read under a
row lock so concurrent moves serialize. Task bucket inserts are chunked
since whole batches now go through one create.
Each project gets a monotonic high-water counter row, backfilled from the
highest existing task index, and an append-only alias table recording
retired (project, index) addresses. Project creation seeds the counter,
project deletion removes both.
The access query joins grants once against the closure table instead of
walking the tree with a recursive CTE, and GetAllParentProjects reads
the chain from it.
One row per project and each ancestor including itself, so the tree
resolves with a join instead of a recursive walk. CreateProject inserts
the chain, UpdateProject re-links the subtree on a parent change and
Project.Delete drops the rows; the relink reads take FOR UPDATE so
concurrent reparents of overlapping subtrees serialize, and a project
without its self row fails loudly instead of silently losing access.
Descendant archiving reads from the table. RepairOrphanedProjects
rebuilds the whole table so drift and rows of deleted projects go away.
One GET /tasks/{id} loaded the task twice (Task.CanRead, then ReadOne), its
project twice and users by id several times, each a round trip.
GetTaskByIDSimple, GetProjectSimpleByID, GetProjectsMapByIDs,
user.GetUserByID and user.GetUsersByIDs now go through the per-session memo,
which drops itself as soon as that session writes anything.
db.Remember and db.RememberEach hold the get-or-fetch dance, mirroring
keyvalue.Remember; the two pre-existing memo sites in project_access.go use
them too. Callers get copies: Project.memoCopy re-allocates ParentProjectID,
and the user memo holds the raw row so the disabled/locked gate runs on
every hit rather than only on the first load.
ReadOne loaded the author with a raw Get, which keeps every column. The list endpoint already goes through getUsersOrLinkSharesFromIDs, which blanks the email and resolves link share authors; ReadOne now does the same.
Every API-token request ran pbkdf2.Key(token, salt, 10000, 50, sha256):
~3 ms CPU per request. Tokens are 160-bit CSPRNG values, so a slow KDF
and salt add nothing; sessions.go already stores refresh tokens as
plain SHA-256 for the same reason. Lookup is now one indexed seek on
token_sha256, falling back to the legacy PBKDF2 scan only for rows
that have not been backfilled yet. A legacy match backfills the column
on its own autocommit session, since callers roll theirs back.
`getSubscriptionsForEntitiesAndUser` built its two recursive-CTE queries
with the entity ids (`entityIDString`) and the user id (`sUserCond`)
interpolated into the SQL text. Every `GET /tasks/{id}` therefore sends
a statement PostgreSQL has never seen: the driver's prepared statement
cache cannot hit, and the planner runs on every call.
Measured on the performance-testing setup (pgx, 3000 simulated users):
the task variant of this CTE was planned once per call, 1.3 ms of
planning for 0.08 ms of execution, and it accounted for half of all
planning time on the database (`pg_stat_statements`: 1985 calls, 1985
plans, 2,558 ms planning vs 166 ms execution in a 75 s window).
## Change
Ids and the user id are bound as `?` parameters; the query text now only
varies with the number of ids, so it is prepared once per connection and
planned a handful of times before PostgreSQL switches to the generic
plan. Argument order follows placeholder order in each query, built up
with a small `add` helper next to the query. Semantics are unchanged;
`TestSubscription*` and `TestGetSubscriptionsForEntity*` pass.
## How to verify
1. Open a task and subscribe/unsubscribe to it and to its project;
notifications for comments and assignments still reach the subscribed
users.
2. On PostgreSQL with `pg_stat_statements.track_planning = on`: the
`WITH RECURSIVE project_hierarchy … t.id IN ($1)` row now shows `plans`
far below `calls`.
---------
Co-authored-by: kolaente <k@knt.li>
Co-authored-by: kolaente <hello@kolaente.de>
lib/pq sends every query as an unnamed prepared statement, so PostgreSQL
plans each statement from scratch. pgx via database/sql keeps a per-connection
statement cache and reuses named prepared statements instead, removing that
planning cost from the hot path.
xorm already ships a "pgx" driver mapping, so the switch is just the driver
name plus the blank import. The connection string format is unchanged: pgconn
parses the same URL and forwards unknown parameters (search_path) as runtime
parameters.
Named prepared statements do not work through a connection pooler in
transaction pooling mode unless it supports them (PgBouncer 1.21 and later,
with max_prepared_statements above 0). Those setups can raise that setting or
connect to PostgreSQL directly, which is no loss since Vikunja already pools
via database.maxopenconnections.
lib/pq was only used for QuoteIdentifier, which is now inlined.
Parsing the zero-date sentinel (`due_date > 0001-01-01 00:00`) in a
timezone east of Greenwich underflows into year 0 on the way to UTC. The
MySQL driver then refuses to encode the bound parameter with "year is not
in the range [1, 9999]: 0" and the whole task collection request fails.
The year clamp was gated on the MySQL dialect, so it only ran where the
dialect was already known at parse time and left other drivers storing BC
dates. Apply it unconditionally instead, and normalize the time entry
filter to UTC like the task filter already does.
Sentry API-OSS-2Q.