Commit Graph

1229 Commits

Author SHA1 Message Date
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
kolaente 3a84c491ae feat(models): let TaskCollection force a flat task list
v1's TaskCollection.ReadAll is polymorphic: a kanban view returns
[]*Bucket, everything else []*Task. v2 splits the task list into a
flat-tasks endpoint and a separate buckets-with-tasks endpoint, so the
flat endpoint needs ReadAll to return tasks even for a kanban view.
SetForceFlatTasks toggles that; v1 leaves it unset and keeps its shape.
2026-06-11 18:31:03 +00:00
kolaente 46b07a019c refactor(user): extract shared account orchestration into models/user/shared for v1+v2
Pull the business logic out of the v1 current-user account/settings handlers
into reusable functions so both v1 and the upcoming v2 handlers call one
implementation. No behavior change — the v1 handlers keep their HTTP-layer
quirks (input binding, validation, error mapping); only orchestration moves.

Homes are forced by the import graph:
- shared.GetAuthProviderName  (new pkg/routes/api/shared, above openid+user so it
                              can combine both without a cycle; routes-only helper)
- user.ChangeUserEmail        (CheckUserCredentials + UpdateEmail, both in user)
- models.ChangeUserPassword   (needs models.DeleteAllUserSessions; user can't import models)
- models.UpdateUserGeneralSettings / UpdateUserAvatarProvider
                              (need avatar.FlushAllCaches; user can't import avatar)

The general settings get a single shared wire struct, models.UserGeneralSettings
(tagged for both swaggo/govalidator and Huma): it is the update request body and
the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2.
ExtraSettingsLinks is readOnly — populated from the user on read, ignored on
write. A dedicated struct is required because user.User's settings fields are
json:"-" so they don't leak when it is embedded in other responses.
2026-06-11 07:02:31 +00:00
kolaente a610ccbbac feat(api/v2): add user webhooks on /api/v2
Port the per-user webhook endpoints (/user/settings/webhooks) from /api/v1 to
the Huma-backed /api/v2: list, available events, create, update, delete. They
are the project-less sibling of the project webhooks (#2858) and share the
webhooks.enabled gate, checked inside the registrar.

Webhook.ReadAll is extended to serve the user-level list (scoped to the
authenticated user) so the v2 list handler can go through handler.DoReadAll like
the project list; the project branch is unchanged. Credentials are masked on
read via the model's existing maskCredentials, matching #2858.
2026-06-10 19:12:41 +00:00
kolaente dc935f263c docs(api/v2): tag task attachment fields for the v2 schema 2026-06-10 10:22:39 +00:00
kolaente cec74717fc refactor(task-attachment): share upload+download via pkg/web/files for v1+v2 2026-06-10 10:22:39 +00:00
kolaente 5cdc785b49 fix(api/v2): return ErrProjectDoesNotExist for unknown project identifiers 2026-06-10 10:12:09 +00:00
kolaente 4316554b27 docs(api/v2): tag task fields for the v2 schema 2026-06-10 10:12:09 +00:00
kolaente 0e0ececa2d docs(api/v2): tag bulk label fields for the v2 schema 2026-06-10 11:56:05 +02:00
kolaente a6a073329f docs(api/v2): tag task position fields for the v2 schema 2026-06-10 11:55:51 +02:00
kolaente f5e7e9ddde docs(api/v2): tag reaction fields for the v2 schema 2026-06-09 21:34:22 +00:00
kolaente da76d393d9 docs(api/v2): tag task relation fields for the v2 schema 2026-06-09 20:42:00 +00:00
kolaente 711545e9f2 docs(api/v2): tag bulk task fields for the v2 schema 2026-06-09 20:13:02 +00:00
kolaente d5bcbe39b4 docs(api/v2): tag project duplication fields for the v2 schema 2026-06-09 20:11:43 +00:00
kolaente 9eca20fe43 docs(api/v2): tag task bucket fields for the v2 schema 2026-06-09 20:01:20 +00:00
kolaente 732cd115a5 docs(api/v2): tag bulk assignee fields for the v2 schema 2026-06-09 19:42:16 +00:00
Claude c9c2c58c16 feat(labels): let bot owners manage labels created by their bots
Bot owners inherit read/update/delete permission on labels created by
bots they own, mirroring the bot-owner branch already used by API tokens
(see api_tokens_permissions.go). Without this, a label a bot creates is
permanently locked to that bot and the human owner cannot maintain it.

https://claude.ai/code/session_016x6mUPJuuQEeXpHY814iLh
2026-06-09 11:40:04 +00:00
kolaente 0bd7f956f5 fix(time-tracking): reject inverted time-entry intervals 2026-06-08 13:54:09 +00:00
kolaente 4a558fc57a fix(api/v2): expose v2-only token route groups via the routes endpoint 2026-06-08 13:54:09 +00:00
kolaente 74510bb00a fix(api/v2): group time-entries token routes under their own scope 2026-06-08 13:54:09 +00:00
kolaente b8b376c53a test(time-tracking): cover the time_entries model 2026-06-08 13:54:09 +00:00
kolaente e197b1912f feat(time-tracking): count tracked time entries per task 2026-06-08 13:54:09 +00:00
kolaente 0c5a0a99ec feat(time-tracking): dispatch time-entry events 2026-06-08 13:54:09 +00:00
kolaente 4bd6a6c4f7 feat(time-tracking): filter time entries with the task DSL 2026-06-08 13:54:09 +00:00
kolaente 42795518e9 feat(time-tracking): add the time_entries model 2026-06-08 13:54:09 +00:00
kolaente 26c067cc38 refactor: extract preprocessFilterString from task filter parsing 2026-06-08 13:54:09 +00:00
kolaente 627cd0a6f4 docs(api/v2): tag project team share fields for the v2 schema 2026-06-07 15:33:20 +00:00
kolaente c2d1e48c8c feat(api/v2): add team members (add/remove/admin-toggle) on /api/v2
The admin-toggle handler delegates to handler.DoUpdate — the same pipeline
v1's UpdateWeb wraps — instead of re-implementing the session/permission/commit
orchestration. TeamMember.Update now carries the persisted row back onto the
receiver so both v1 and v2 responses include id/created.
2026-06-07 10:48:23 +00:00
kolaente ef256273e0 docs(api/v2): annotate TeamMember fields for the v2 schema 2026-06-07 10:48:23 +00:00
kolaente a52ee1593a docs(api/v2): tag SavedFilter fields for the v2 schema 2026-06-07 10:40:20 +00:00
kolaente 2c0608e47b docs(api/v2): tag project user share fields for the v2 schema 2026-06-07 10:37:59 +00:00
kolaente 05c9c07e19 docs(api/v2): add doc/readOnly tags to notification model fields 2026-06-07 10:05:24 +00:00
kolaente 1b47932916 feat(api/v2): add subscribe/unsubscribe on /api/v2
Port the Subscription resource from /api/v1 to the Huma-backed /api/v2:
POST /subscriptions/{entity}/{entityID} subscribes, DELETE unsubscribes.

The {entity} discriminator is bound as a string path param with an
enum:"project,task" tag; the model's CanCreate/CanDelete derive the numeric
EntityType from it and reject unknown kinds. Permissions and the
already-subscribed/forbidden checks come from the shared model via DoCreate/
DoDelete, identical to v1's generic handler. Mark the model's server-controlled
fields readOnly and add doc tags for the v2 schema.
2026-06-07 09:57:51 +00:00
kolaente 5855ccc1d4 docs(webhooks): version-qualify the events endpoint link in the events field doc
In the v2 OpenAPI context a bare /webhooks/events reads as /api/v2/webhooks/events,
which does not exist — the events listing endpoint lives only on /api/v1. Point the
doc string at the absolute v1 path so v2 clients are not misled.
2026-06-06 19:50:41 +00:00
kolaente aac0322975 refactor(webhooks): mask write-only credentials in the model so create/update never echo them
Webhook.ReadAll already cleared the secret and basic-auth from responses,
but Create and Update did not, so the v2 handler patched the gap with a
maskWebhookCredentials helper. Centralize the masking in the model via a
maskCredentials helper called after every DB write (ReadAll, Create,
Update) and drop the v2 handler helper.

The credentials are client-provided, not server-generated: the DB row
keeps them and outgoing deliveries reload + HMAC-sign from the DB copy,
so clearing the returned in-memory struct is correct write-only handling.

Webhook is a shared model, so v1's create/update responses also stop
echoing the submitted secret/auth — intended, and approved by the
maintainer.
2026-06-06 19:50:41 +00:00
kolaente 3647551a79 docs(api/v2): tag Webhook fields for the v2 schema
Add doc tags to every exposed Webhook field, mark the server-controlled
ones (id, project_id, user_id, created_by, created, updated) readOnly,
and mark the secret and basic-auth credentials writeOnly. All three tags
are ignored by swaggo/XORM/govalidator, so v1 is unaffected.
2026-06-06 19:50:41 +00:00
kolaente d76c009808 fix(api/v2): map ValidationHTTPError to its HTTP status
translateDomainError only recognized web.HTTPErrorProcessor, so a
ValidationHTTPError from InvalidFieldError (e.g. an unknown webhook
event) leaked as a 500 instead of the 412 v1 returns. It carries the
status via GetHTTPCode() but cannot implement HTTPErrorProcessor because
the embedded web.HTTPError field shadows the method name. Add a
GetHTTPCode/GetCode branch so v2 surfaces the right status and preserves
the v1 numeric code on the body.
2026-06-06 19:50:41 +00:00