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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
Add doc: tags so Huma can describe user_id and created in the /api/v2
OpenAPI spec (it can't read Go comments), mark the server-set created
field readOnly, and give it an explicit json:"created" tag so it
serializes in snake_case like the rest of the v2 surface.