Description
Purpose and use
Profile gives a signed-in user the context needed to work inside CoreBanq: identity, customer links, available roles, and tray data used by the application shell.
Who uses this. Every authenticated staff or customer user uses profile data indirectly when the application loads their workspace, permissions, and customer context.
How it works. The profile tray combines user identity with customer memberships, role information, and related context needed to decide which records and screens the user can access.
What users do. Users open the application, switch or review customer context where allowed, and rely on profile data to see the correct navigation and operational scope.
Outcomes and side effects. Profile reads are contextual and do not post money or change business records. Incorrect profile context can hide records or expose the wrong workspace, so role and customer links should be reviewed through the user and RBAC manuals.
Related manuals: Users, Roles, RBAC, Customers.
Overview
Two read-only routes, both scoped to the signed-in user by the query itself — neither handler runs a record-level RBAC check, because the tray view already filters on the user id.
Endpoints
Get profile tray
GET /v1/profile/tray
Reads profiles.v_userlinks filtered to the caller. Returns a bare array — no envelope, no
total, no pagination — and reads no query parameter. Always an array; [] when the user is
linked to nothing. The order is unspecified: the query has no ORDER BY and the view is a
UNION ALL of two branches, so Postgres may return the rows in any order and that order may change
with the plan. Sort client-side if the switcher needs a stable one.
Success response (200):
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Personal Profile",
"type": "company",
"status": "ACTIVE",
"route": "/company"
}
]Two fields are rewritten on the way out, both resolved for the request's Accept-Language:
name— when the view's column isNULLor an empty string, the server substitutes theprofile_m.new_company_texttemplate with{id4}replaced by the last four characters ofid.type— the view SELECTs this as a literal, so there are exactly two values before the rewrite:companyfor a customer link, andkyb_flowfor a KYB flow with no customer row yet.companyis sent unchanged and is what the metrics route filters on;kyb_flowis replaced byprofile_m.kyb_flow_text. The Go code also maps aNULLtype to"", but the view cannot produce one.
status is customers.customers.status for a company entry — the platform vocabulary is
uppercase (KYB, SIGN, REVIEW, ACTIVE, SUSPENDED), which is why GetCustomerRoute
matches on KYB and SIGN — and the literal KYB for a kyb_flow entry, so those rows always
route to /company/kyb.
Neither rewrite produces end-user text. Both templates are tenant configuration, and every
shipped bundle seeds the same frontend translation token in all five of its languages — cz, de,
en, fr, it — so what actually arrives is
TEXT.COMPANY_LIST__KYB_COMPANY_NAME ** and TEXT.COMPANY_LIST__KYB_COMPANY_STATUS, for the
client to resolve.
An unseeded language empties both fields. config.GetMessage has no default-language fallback:
an Accept-Language resolving to anything outside the seeded set returns an empty template, so a
blank name arrives as "" after all and a kyb_flow row's type arrives as "". Every
kyb_flow row is exposed to this: the view's second branch matches only flows whose customer row
does not exist, so their name is always NULL and always goes through the template. config.GetLanguage
makes this easier to hit than it looks — it takes the first member of the header and keeps the part
before the region subtag, but does not strip a q-value, so de-CH,de is read as de while
de;q=0.9 is read as the language de;q=0.9. An absent or empty header falls back to the
configured default language instead, and is safe.
route is derived from status by customers.GetCustomerRoute, and takes exactly three values:
/company/kyb for status KYB, /company/documents for status SIGN, and /company for
everything else, an empty status included. The Go tag carries omitempty, but the default branch
returns the base path rather than an empty string, so the field is always present.
Get portfolio metrics
GET /v1/profile/portfolio-metrics
Counts users, accounts and payments for each requested company. This route was absent from the OpenAPI spec entirely until now.
customer_ids is effectively required. It reads like an optional filter, but omitting it does
not mean "every company in my tray" — the authorisation step returns an empty set for an empty
request, so the answer is 200 {"data": []}. Send the ids you want.
Ids you may not see are dropped silently. The requested ids are intersected with the companies
in the caller's own tray — entries whose type is exactly company — and anything outside that
set is removed with no error and no marker. A response can be shorter than the request, and the
only way to tell which ids survived is to compare customer_id values.
Query parameters
customer_ids— comma-separated UUIDs. Whitespace around each is trimmed, empty members are skipped, and duplicates are collapsed. At most 32 after de-duplication; more is400 profile.portfolio_metrics_too_many_customers. A member that does not parse fails the whole request with400 profile.portfolio_metrics_invalid_customer— there is no partial parse.
Success response (200):
{
"data": [
{
"customer_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "ok",
"metrics": { "users": 12, "accounts": 4, "payments": 318 }
},
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"status": "error",
"error": { "code": "profile.portfolio_metrics_customer_failed" }
}
]
}A per-company failure does not fail the request. That row comes back with status: "error" and
an opaque code while the others still carry metrics — the response is still 200. Exactly one of
metrics and error is present, chosen by status. Companies are counted concurrently, at most
six at a time, so row order follows the request rather than completion.
Errors
The envelope
{"status": 400, "message": "One or more customer IDs are invalid", "code": "profile.portfolio_metrics_invalid_customer", "class": "validation"}message is the resolved i18n template for code. code, class, retryable and details are
stamped only outside the 2xx range. ClassForStatus has no empty branch: 400 and 422 are
validation; 408, 429, 502, 503 and 504 are temporary and additionally carry
retryable: true; everything else, 500 included, is business. details is never populated by
this module.
Codes
The module's own codes use two different prefixes — profile. and profile_m. — and this is
not a typo in the documentation. The rest of the table is codes from shared packages that these
two routes forward unchanged:
| Code | Where |
|---|---|
profile_m.invalid_file_type | Declared, but produced by no route in this module |
profile.portfolio_metrics_too_many_customers | 400 — more than 32 distinct customer_ids |
profile.portfolio_metrics_invalid_customer | 400 — a member of customer_ids is not a UUID |
profile.portfolio_metrics_customer_failed | Not an envelope code — it is the value of error.code inside a 200 row |
db_m.failed_to_run_query | 500 — the tray query or its row scan failed |
auth.failed_to_extract_claims | 500 — the handler ran with no claims in the request context |
auth.failed_to_parse_user_id | 500 — claims are present but their user id does not parse |
common.server_error | 500 — the transfer lister is not configured, or one of auth.RateLimitMiddleware's checks failed while the switcher is on: the RBAC role fetch, the caching of the caller's limits, the cached-limits lookup, or the counter increment against the rate-limit cache |
Three codes this page used to list do not exist as constants anywhere: profile.not_found,
profile.unauthorized, and profile.invalid_file_type under that spelling — the real constant is
profile_m.invalid_file_type, and nothing in this module raises it.
profiles.v_userlinks failing is reported with db_m.failed_to_run_query built without
WithCode, so HandleAppErrorWithCode short-circuits on the empty Code before reaching its status
switch at all. The row-scan failure carries WithCode(500) and takes the switch's 500 case. Same
status either way.
A missing user id is not common.server_error. Both handlers open with auth.GetUserID, which
returns auth.failed_to_extract_claims or auth.failed_to_parse_user_id, and pass that AppError
to apireply.InternalServerError500. The helper's own default code is substituted only when it is
called with no AppError, which neither handler does — so a client branching on code sees one
of the two auth. codes here, on both routes.
Rate limiting
auth.RateLimitMiddleware is mounted on the root router, above every route in the service — but
only when the AppConfig flag rate_limits.rate_limits_switcher is true. reads that flag
once at bootstrap and calls r.Use inside the if, so with the flag off the middleware is absent
from the chain entirely — not mounted and idle — and no route can answer 429. Changing the
flag takes a restart. When it does fire the
429 code is not common.too_many_requests — that key is only the fallback the reply helper
stamps when called with no AppError. The codes that really arrive are rate_limits_m.exceeded,
rate_limits_m.global_exceeded and rate_limits_m.failed_to_increment_ip_limit, plus two free-text
strings that are not i18n keys at all: rate limit exceeded and Global rate limit exceeded. The
message for rate_limits_m.exceeded interpolates two parameters — {IP} is the caller's address and
{endpoint} the uuid of the matched RBAC endpoint — so it never arrives as a bare sentence.
Refusals the middleware writes before the handler runs
These apply to both routes, but they do not all come from the same place. health.LifecycleMiddleware
and — when the switcher is on — auth.RateLimitMiddleware are mounted on the root router.
auth.Middleware is not: the module wraps each of its own handlers in auth.WrapWithMiddlewares
in . A route added to this module without that wrapper gets no authentication, no
RBAC grant check and no licence check at all.
| Status | Code | Cause |
|---|---|---|
401 | common.unauthorized | auth.Middleware: no bearer token, no Bearer prefix, a token that does not parse, a blacklisted token, or an error during the blacklist lookup. All five call Unauthorized401 with no AppError, so the body carries the helper's default |
401 | permission denied — free text, not an i18n key | auth.RateLimitMiddleware, and only while the switcher is on. It runs on the root router, ahead of the per-route auth.Middleware, and resolves the endpoint against the caller's cached limits — which are built only from endpoints the caller has an RBAC row for. No match returns errs.New("permission denied").WithCode(401), so an authenticated caller without the endpoint grant gets this 401 and never reaches the 403 on the next row |
403 | common.rbac_no_rec_access → No access to the record | rbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code |
403 | license_m.license_invalid, license_m.license_expired, license_m.module_not_licensed, license_m.license_key_missing | The licence branch. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused. license_key_missing is not a tenant problem — it means licenseService was nil, so the server came up without a usable COREBANQ_LICENSE_KEY and refuses every route until it is restarted. A fifth code, license_m.license_service_unavailable, is matched by the middleware but is written to the licence-error context by no code path, so it never reaches a client |
500 | common.server_error | auth.RateLimitMiddleware, when the switcher is on and any of its checks fails — the RBAC role fetch, the caching of the caller's limits, the cached-limits lookup, or the counter increment against the rate-limit cache. All of them reach handleRateLimitError's default branch, which calls InternalServerError500 with no AppError, so the body carries the helper's default — indistinguishable by code either from each other or from the handler's own common.server_error |
503 | {"overall_status": "unhealthy", …} — not the envelope | Graceful shutdown. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — but not the rate limiter, which registers one r.Use earlier when the switcher is on. With it on, a request arriving during a drain can be answered 429, or 401 permission denied, instead of this 503 |
503 | auth_m.internal_server_error → Internal server error | The auth cache is unhealthy. ensureCacheAvailable runs before the blacklist lookup, so an unreachable Redis/valkey refuses every authenticated request |
The 503 is two different bodies, and a client has to branch on the shape rather than assume
the envelope. While the server is draining — and no earlier middleware has already refused the
request — health.LifecycleMiddleware writes a bare map —
{"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} —
with no status, code, class or retryable field on it at all, and whose message is a fixed
English string, not an i18n key. The auth-cache 503 is the envelope, and its code is
auth_m.internal_server_error — not common.server_error: ensureCacheAvailable builds it from
the auth package's own MsgInternalServerError, which shadows the errs constant of the same name
and resolves to a different code. It carries WithCode(503), so it is handed to the reply helper
and the helper's own default is never substituted. The rendered message is no help either — both
keys are seeded with the same text in all five languages, so only code tells them apart.
Response status codes
| Status | Where |
|---|---|
| 200 | Both routes |
| 400 | customer_ids unparseable or over 32 |
| 401 | Auth middleware, or the rate limiter denying the endpoint when the switcher is on |
| 403 | Endpoint grant or licence |
| 429 | Rate limiter, when the switcher is on |
| 500 | Tray query failure, missing user context, unconfigured dependency, or the rate limiter's own lookup failing |
| 503 | Graceful shutdown, or the auth cache |
Neither route can answer 404. There is no lookup by id here: the tray is a filtered read and
the metrics route drops unknown ids instead of reporting them.
Deletes a product from the system
Counts users, accounts and payments for each requested company. customer_ids IS EFFECTIVELY REQUIRED. It reads like an optional filter, but omitting it does NOT mean "every company in my tray": the authorisation step returns an empty set for an empty request, so the answer is 200 {"data": []}. Send the ids you want. IDS YOU MAY NOT SEE ARE DROPPED SILENTLY. The requested ids are intersected with the companies in the caller's own tray — entries whose type is exactly "company" — and anything not in that set is removed with no error and no marker. A response can therefore be shorter than the request, and the only way to tell which ids survived is to compare customer_id values. A per-company failure does NOT fail the request: that row comes back with status "error" and an opaque code while the others still carry metrics. Companies are counted concurrently, at most six at a time.