Categories API
This page describes the web app endpoints that list and create product categories ("Tipo de Producto"), including the ?organizationId= scoping rules for partner and vendor organizations.
Routes: apps/web/server/api/categories/. Controller: modules/inventory/src/categories/infrastructure/controllers/Category.ts. Repository: modules/inventory/src/categories/infrastructure/repositories/PrismaCategory.ts.
Caller classification
| Caller | Condition (_resolveOrgScope) |
|---|---|
| Partner | The caller organization has no partnerId |
| Vendor | The caller organization has a partnerId |
Classification uses organizations.partner_id, not organizations.type.
?organizationId= resolution
Applies to GET /api/categories and POST /api/categories.
| Caller | organizationId | Scope | Result |
|---|---|---|---|
| Vendor | any value or absent | Ignored | Own organization |
| Partner | absent | — | GET: all vendors. POST: own organization |
| Partner | own id | Own organization | GET: own categories. POST: own organization |
| Partner | id of one of its vendors | That vendor | GET: that vendor's categories. POST: stored in that vendor |
| Partner | any other id | — | 403 La organización no pertenece a este partner |
GET /api/categories
| Item | Value |
|---|---|
| Permission | org:categories:read |
Query search | Case-insensitive substring match on name |
Query limit | Page size. Default 50 when search is set, otherwise unlimited |
Query offset | Rows to skip |
Query organizationId | See resolution |
| Order | created_at DESC |
Results by scope
| Scope | Repository call | Rows returned |
|---|---|---|
| Vendor, or partner with its own id | find(callerOrgId) | Every category visible under RLS with app.organization_id = callerOrgId (no explicit organization filter) |
| Partner with a vendor id | findByOrgIds(callerOrgId, [vendorId]) | Categories where organization_id = vendorId |
Partner without organizationId | findByOrgIds(callerOrgId, allVendorIds) | Categories of all vendors. [] when the partner has no vendors |
Response 200
Array of categories:
| Field | Type |
|---|---|
id | number |
name | string (lowercase) |
description | string or undefined |
organizationId | string |
organization | { id, name } |
createdAt | date |
updatedAt | date or undefined |
POST /api/categories
| Item | Value |
|---|---|
| Permission | org:categories:create |
Query organizationId | See resolution |
| Body | { "name": string, "description"?: string } |
| Stored name | name.toLowerCase().trim() |
| Stored organization | Resolved scope (own organization or the selected vendor) |
Responses
| Status | Body | Cause |
|---|---|---|
201 | Created category | — |
400 | Invalid body | Body missing or not an object |
403 | Insufficient permissions: org:categories:create required | Missing permission |
403 | La organización no pertenece a este partner | organizationId is not the partner or one of its vendors |
409 | Ya existe un tipo de producto con ese nombre | A category with that name is visible in the target organization's RLS context, or the insert violates categories_name_organization_id_key |
500 | An unknown server error occurred. (code: <prisma code>) | Any other database error |
Error mapping in PrismaCategory
Raw queries surface Postgres errors as Prisma P2010.
| Prisma code | SQLSTATE | Constraint in detail | Result |
|---|---|---|---|
P2010 | 23505 | categories_name_organization_id_key | 409 Ya existe un tipo de producto con ese nombre |
| any other | any | any | Logged, then PrismaErrorHandler (500 for P2010) |
SQLSTATE is read from meta.code, or parsed from the message text (Code: \23505``). Log line format:
[PrismaCategory.<operation>] <prisma code> sqlState=<sqlstate or n/a>: <detail>
POST /api/categories/resolve
| Item | Value |
|---|---|
| Permission | org:categories:read |
| Body | { "names": string[] } |
| Behavior | Looks up each name and creates missing ones in the caller organization |
| Response | Record<string, number> (original name → category id) |
400 | Se requiere un array de nombres de tipos de producto when names is empty or missing |
RLS facts
| Fact | Detail |
|---|---|
| Table | categories has RLS enabled (categories_all_policy) |
| Visible rows | organization_id = app.organization_id OR organization_id belongs to a vendor of app.organization_id |
| Binding | Every repository method runs set_config('app.organization_id', $1, true) and the query in one transaction |
findByOrgIds | Binds the caller (partner) organization, then filters by organization_id IN (...) |
create | Binds the target organization (the vendor when a partner creates for a vendor) |
| Duplicate check | findByNames runs in the target organization's RLS context |
UI consumer
apps/web/components/product/form/CategoryAutocomplete.vue sends organizationId when its organization-id prop is set. The edit page passes the product owner organization (product.organization.id). The create page does not pass it.
Related
- Policy shape and ownership: Partner and vendor ownership
- Why raw queries bind the organization: RLS and raw queries
- Sequence errors on
categories_pkey: How to fix the categories id sequence