# API de grupos de usuarios

> Crea, lista, obtén, actualiza y elimina grupos de usuarios de la organización, y gestiona su membresía, a través de la API para integradores.

Los grupos de usuarios son colecciones de miembros, delimitadas por organización, que puedes gestionar
en conjunto. Cada grupo tiene un ULID asignado por el servidor, un nombre único dentro de la
organización (sensible a mayúsculas y minúsculas), una descripción opcional, un conteo de miembros
calculado por el servidor y un conteo de proyectos calculado por el servidor. Un grupo puede
[asignarse a la lista de acceso de un proyecto](https://docs.probara.net/es/developers/api/project-access/#lista-de-acceso-de-grupos),
otorgando a todos los miembros actuales y futuros del grupo acceso a ese proyecto en una sola
operación.

Un grupo gestionado por tu proveedor de identidad tiene `managedByIdp` en `true`. Su nombre y sus miembros se cambian en el proveedor de identidad: renombrarlo, eliminarlo o añadir o quitar un miembro devuelve `409 conflict` con el mensaje «This group is managed by your identity provider. Change its name and members there.». Su descripción y sus concesiones de proyecto siguen siendo editables.

## Crear

`POST /api/v1/groups`

Requiere el permiso de gestión de grupos de miembros (que tienen **`owner`**/**`admin`**); **`member`** y **`viewer`** reciben
**`403 forbidden`**.

### Cuerpo de la solicitud

| Campo             | Requerido | Notas                                                                                            |
| ----------------- | --------- | ------------------------------------------------------------------------------------------------ |
| `name`            | sí        | 1–120 caracteres, sin espacios al inicio/fin. Debe ser único entre los grupos de la organización |
| `description`     | no        | Hasta 500 caracteres, u omitirlo/`null` para no tener ninguna                                    |
| `memberUserUlids` | no        | ULIDs de miembros activos de la organización para agregar como miembros iniciales, hasta 200     |
| `projectUlids`    | no        | ULIDs de proyectos de la organización para agregar como asignaciones iniciales, hasta 200        |

Un nombre duplicado dentro de la misma organización devuelve `409 conflict`. El mismo nombre se acepta
en una organización distinta.

`memberUserUlids` incorpora la membresía del grupo en la misma solicitud atómica que lo crea: el grupo
y sus miembros incorporados se confirman todos juntos o ninguno, nunca queda un grupo creado con
algunos miembros faltantes. Los ULIDs repetidos se deduplican silenciosamente. Cualquier entrada que
nombre a alguien que ya dejó la organización (o que nunca fue miembro) rechaza la creación **completa**
con `404 not_found` y no crea nada, ni siquiera la fila del grupo. Cada miembro incorporado emite su
propio evento de auditoría `user_group.member_added` (ver [Eventos de auditoría](#eventos-de-auditoría)),
con la misma forma que un miembro agregado después mediante [Agregar un miembro](#agregar-un-miembro):
el feed de actividad no puede distinguir uno del otro.

`projectUlids` incorpora las [asignaciones de proyectos](#listar-proyectos-asignados) iniciales del
grupo en la misma solicitud atómica, siguiendo la misma convención: los ULIDs repetidos se deduplican
silenciosamente, y una entrada que nombre un proyecto fuera de la organización activa rechaza la
creación **completa** con `404 not_found`, sin dejar fila de grupo, fila de membresía ni fila de
asignación. Una asignación incorporada **nunca** se rechaza porque el modo de acceso almacenado del
proyecto destino sea `"public"`: un grupo suele crearse antes de decidir cuáles de sus proyectos serán
privados, así que una asignación latente sobre un proyecto público es el caso común, no un error. Cada
asignación incorporada emite su propio evento de auditoría `project.access_changed` en el feed de
actividad del **proyecto**, con `reason: "group_granted"`, con la misma forma que una asignación hecha
después mediante [Asignar proyectos](#asignar-proyectos) o los
[endpoints de grupo del plano de proyecto](https://docs.probara.net/es/developers/api/project-access/#lista-de-acceso-de-grupos):
quien lea el historial de acceso de un proyecto no puede distinguir qué vía la produjo. Las
asignaciones incorporadas no llevan una verificación de autorización adicional: crear un grupo ya
requiere **`owner`**/**`admin`**, y ambos roles ya pueden asignar cualquier proyecto de la organización.

Ni `memberUserUlids` ni `projectUlids` se aceptan en ningún otro endpoint. [Actualizar](#actualizar) no
acepta ninguno de los dos — una clave desconocida rechaza toda la solicitud con `422`. Incorporar
miembros o proyectos después de la creación se hace a través de
[Agregar un miembro](#agregar-un-miembro) y de [Asignar proyectos](#asignar-proyectos).

Admite el [almacén de idempotencia](https://docs.probara.net/es/developers/api-reference/#errores) del plano de tenant: repetir
una creación con el mismo encabezado `Idempotency-Key` resuelve al grupo original (con sus miembros
incorporados) en lugar de crear uno duplicado.

### Respuesta

`201` con el grupo creado. Ver [Campos de la respuesta](#campos-de-la-respuesta).

### Ejemplo

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Equipo de QA","description":"A cargo del ciclo de regresión"}' \
  "https://app.probara.net/api/v1/groups"
```

Incorporando miembros y proyectos en la misma solicitud:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"Equipo de QA","memberUserUlids":["01JMEMBERXXXXXXXXXXXXXXXXXX"],"projectUlids":["01JPROJECTXXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups"
```

## Listar

`GET /api/v1/groups`

Devuelve los grupos de la organización, ordenados por nombre ascendente. Paginación basada en
páginas. Lectura abierta para cualquier miembro de la organización, sin importar su rol.

### Parámetros de consulta

| Parámetro  | Notas                                                                                             |
| ---------- | ------------------------------------------------------------------------------------------------- |
| `page`     | Número de página (basado en 1, por defecto `1`)                                                   |
| `pageSize` | Elementos por página (por defecto `50`, máximo `200`)                                             |
| `q`        | Búsqueda de subcadena sin distinción de mayúsculas sobre el nombre **y** la descripción del grupo |

### Respuesta

`200` con `{ items, page, pageSize, total }`, cada elemento con la [forma de la respuesta](#campos-de-la-respuesta).

### Ejemplo

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups?q=qa"
```

## Obtener

`GET /api/v1/groups/{userGroupUlid}`

Devuelve un solo grupo delimitado a la organización activa. Lectura abierta para cualquier miembro de
la organización.

Devuelve `404 not_found` cuando el grupo está fuera de la organización activa.

### Ejemplo

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Actualizar

`PATCH /api/v1/groups/{userGroupUlid}`

Actualización parcial: ambos campos son opcionales, con la misma validación que [Crear](#crear).
Requiere **`owner`** o **`admin`**.

Renombrar a un nombre ya usado por otro grupo de la organización devuelve `409 conflict`; el grupo
original conserva su nombre. Un grupo gestionado por tu proveedor de identidad responde a un cambio de nombre con el `409 conflict` anterior, y su descripción sigue siendo editable. Devuelve `404 not_found` cuando el grupo está fuera de la organización
activa.

Ni `memberUserUlids` ni `projectUlids` son campos aceptados aquí, deliberadamente: una clave
desconocida rechaza toda la solicitud con `422`. Los cambios de membresía posteriores a la creación se
hacen a través de [Agregar un miembro](#agregar-un-miembro) y
[Quitar un miembro](#quitar-un-miembro); los cambios de asignación de proyectos se hacen a través de
[Asignar proyectos](#asignar-proyectos) y de los
[endpoints de grupo del plano de proyecto](https://docs.probara.net/es/developers/api/project-access/#lista-de-acceso-de-grupos).

### Ejemplo

```bash
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"description":"A cargo del ciclo de regresión de lanzamiento"}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Eliminar

`DELETE /api/v1/groups/{userGroupUlid}`

Requiere **`owner`** o **`admin`**. Devuelve `204` cuando se completa; las filas de membresía del grupo
se eliminan con él (una cascada por clave foránea; no hace falta ni es posible vaciar un grupo antes por
una llamada separada). Eliminar un grupo con miembros nunca se rechaza: no existe una precondición de
vacío.

Devuelve `404 not_found` cuando el grupo está fuera de la organización activa.

### Ejemplo

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Listar miembros

`GET /api/v1/groups/{userGroupUlid}/members`

Devuelve los miembros del grupo, ordenados por fecha de ingreso ascendente. Paginación basada en
páginas (`page`, `pageSize`; los mismos valores por defecto que [Listar](#listar)). Lectura abierta
para cualquier miembro de la organización. Un miembro que dejó la organización queda excluido tanto de
esta lista como del `userCount` del grupo.

### Respuesta

`200` con `{ items, page, pageSize, total }`. Cada elemento tiene la misma forma que un miembro de la
organización (ver [Organizaciones](https://docs.probara.net/es/developers/api-reference/)), sin el campo `joinedAt`.

### Ejemplo

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

## Agregar un miembro

`POST /api/v1/groups/{userGroupUlid}/members`

Requiere **`owner`** o **`admin`**. Agrega UNO o VARIOS miembros en la misma solicitud — es una sola
operación sobre una sola ruta, no dos.

### Cuerpo de la solicitud

Exactamente una de estas dos formas — un cuerpo con ambas claves, o con ninguna, responde
`422 validation_failed`:

| Campo       | Requerido      | Notas                                                                       |
| ----------- | -------------- | --------------------------------------------------------------------------- |
| `userUlid`  | una de las dos | ULID de un único miembro activo de la organización (la forma original)      |
| `userUlids` | una de las dos | Arreglo de ULIDs de miembros activos a agregar juntos, hasta 200, `.min(1)` |

`userUlids` se deduplica silenciosamente antes de resolverse. La escritura es **todo o nada**: un ULID
que ya dejó la organización (o que nunca fue miembro) en cualquier parte de la solicitud — incluida la
forma escalar `userUlid` — rechaza la solicitud **completa** con `404 not_found` y no agrega a nadie.
Esto vale incluso para un ULID que ya es miembro: la verificación de miembro activo corre antes que la
verificación de "ya es miembro", así que un miembro existente que ya dejó la organización sigue
respondiendo `404`, nunca `409`.

Una solicitud que no agregaría **ninguna** membresía nueva — todos los ULID listados ya son
miembros — responde `409 conflict` y no agrega nada; es exactamente el comportamiento que
`{ userUlid }` ya tenía para un único ULID que ya era miembro, generalizado y no reemplazado. Una
selección que mezcla miembros nuevos y existentes agrega solo los nuevos y sigue respondiendo `201`.

Admite el mismo encabezado `Idempotency-Key` que [Crear](#crear).

### Respuesta

`201` con el cuerpo vacío. No se reporta ningún resultado por miembro — una fila de membresía no
tiene ningún efecto secundario no transaccional que reportar.

### Ejemplo

Agregar un miembro (sin cambios):

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userUlid":"01JMEMBERXXXXXXXXXXXXXXXXXX"}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

Agregar varios miembros en una sola solicitud:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userUlids":["01JMEMBERAXXXXXXXXXXXXXXXX","01JMEMBERBXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

## Quitar un miembro

`DELETE /api/v1/groups/{userGroupUlid}/members/{userUlid}`

Requiere **`owner`** o **`admin`**. Quitar a alguien **no** requiere que su membresía esté activa: la
fila de un miembro que ya dejó la organización también puede quitarse. Devuelve `204` cuando se
completa; `404 not_found` cuando el par no existe.

### Ejemplo

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members/01JMEMBERXXXXXXXXXXXXXXXXXX"
```

## Listar proyectos asignados

`GET /api/v1/groups/{userGroupUlid}/projects`

Devuelve los proyectos a los que está asignado este grupo, ordenados por nombre ascendente.
Paginación basada en páginas (`page`, `pageSize` — mismos valores por defecto que
[Listar](#listar)). Abierto a lectura para cualquier miembro de la organización, como los otros
tres `GET` de grupo de arriba — **pero, a diferencia de ellos**, este listado filtra sus
resultados: un proyecto privado que el llamante no pueda descubrir de otra forma (ver
[Acceso a proyectos](https://docs.probara.net/es/developers/api/project-access/)) se omite silenciosamente tanto de
`items` como de `total`. Un **`owner`**/**`admin`** de la organización ve todas las asignaciones,
ya que ese rol ya puede descubrir cualquier proyecto.

Esta es deliberadamente una pregunta **distinta** al `projectCount` propio del grupo, más abajo,
que se mantiene como un conteo sin filtrar — una asignación privada que el llamante no puede ver
sigue contando para `projectCount`, aunque esté ausente de este listado. Un conteo no es un
nombre.

### Respuesta

`200` con `{ items, page, pageSize, total }`. Cada elemento:

| Campo           | Tipo           | Notas                                                                        |
| --------------- | -------------- | ---------------------------------------------------------------------------- |
| `ulid`          | string (ULID)  | Identificador único del proyecto, asignado por el servidor                   |
| `id`            | string         | El código del proyecto, de cara al usuario (por ejemplo `ACME`)              |
| `name`          | string         | El nombre del proyecto                                                       |
| `mode`          | string         | El modo de acceso propio y almacenado del proyecto, `"public"` o `"private"` |
| `avatarKey`     | string \| null | Clave de almacenamiento del avatar del proyecto; `null` si no tiene          |
| `avatarVersion` | integer        | Versión del avatar para invalidar caché; `0` si no tiene                     |

```json
{
  "items": [
    {
      "ulid": "01J...PROJECT",
      "id": "ACME",
      "name": "Acme Corp",
      "mode": "private",
      "avatarKey": "avatars/projects/01J...PROJECT.webp",
      "avatarVersion": 3
    }
  ],
  "page": 1,
  "pageSize": 50,
  "total": 1
}
```

### Ejemplo

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/projects"
```

## Asignar proyectos

`POST /api/v1/groups/{userGroupUlid}/projects`

Asigna el grupo a uno o varios proyectos en la misma solicitud — un lote es una sola operación sobre
una sola ruta, no N llamadas a los
[endpoints de grupo del plano de proyecto](https://docs.probara.net/es/developers/api/project-access/#lista-de-acceso-de-grupos).
**Sin ninguna barrera de rol de organización propia.** La autorización corre **por proyecto**, dentro
de la misma transacción, con la misma regla que ya aplica
`POST /api/v1/projects/{projectId}/access/groups` (`owner`/`admin` de la organización, o el propietario
actual del proyecto) — apilar encima una barrera de gestión de grupos volvería ese chequeo por proyecto
inalcanzable como rechazo para un llamante `member`/`viewer` que legítimamente es propietario de un
proyecto, y deliberadamente no se hace aquí.

### Cuerpo de la solicitud

| Campo          | Requerido | Notas                                              |
| -------------- | --------- | -------------------------------------------------- |
| `projectUlids` | sí        | ULIDs de proyectos a asignar, hasta 200, `.min(1)` |

Una clave desconocida, un arreglo vacío o más de 200 ULIDs distintos responde `422 validation_failed`.

La escritura es **todo o nada**: un ULID de proyecto que no puede resolverse, o uno para el que el
llamante no está autorizado a asignar, en cualquier parte del lote rechaza la solicitud **completa** y
no confirma nada — incluso un proyecto anterior en el mismo lote para el que el llamante **sí** estaba
autorizado. Un ULID de proyecto no resoluble responde `404 not_found`; un fallo de autorización responde
`403 forbidden`. Los ULIDs repetidos dentro de una misma solicitud se deduplican silenciosamente a una
sola asignación. Un proyecto ya asignado dentro del lote nunca es un error (coincide con la semántica
propia de `onConflictDoNothing` de la lista de acceso) — la solicitud sigue respondiendo `201`, nunca
`409`, y el resto del lote sigue confirmándose. Un proyecto público destino nunca se rechaza, igual que
las asignaciones incorporadas de [Crear](#crear).

Cada asignación nueva escrita emite su propio evento de auditoría `project.access_changed` en el feed
de actividad del **proyecto**, con `reason: "group_granted"` — incluida una reasignación de un proyecto
ya asignado, ya que el delegado subyacente emite el evento incondicionalmente.

Admite el mismo encabezado `Idempotency-Key` que [Crear](#crear).

### Respuesta

`201` con el cuerpo vacío.

### Ejemplo

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"projectUlids":["01JPROJECTAXXXXXXXXXXXXXXXX","01JPROJECTBXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/projects"
```

## Campos de la respuesta

Todos los endpoints de mutación (`POST`, `PATCH`) y `GET /api/v1/groups/{userGroupUlid}` devuelven un
solo objeto de grupo.

| Campo          | Tipo           | Notas                                                                                                                                                                      |
| -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ulid`         | string (ULID)  | Identificador único asignado por el servidor                                                                                                                               |
| `name`         | string         | 1–120 caracteres, único dentro de la organización                                                                                                                          |
| `description`  | string \| null | Descripción opcional, hasta 500 caracteres                                                                                                                                 |
| `userCount`    | number         | Conteo de miembros activos, derivado de las filas de membresía del grupo                                                                                                   |
| `projectCount` | number         | Conteo real y sin filtrar de proyectos asignados al grupo — ver [Listar proyectos asignados](#listar-proyectos-asignados) para el listado de nombres filtrado por llamante |
| `createdAt`    | number         | Milisegundos desde epoch Unix                                                                                                                                              |
| `updatedAt`    | number         | Milisegundos desde epoch Unix                                                                                                                                              |

## Eventos de auditoría

Las mutaciones de grupos emiten los siguientes eventos en el feed de actividad de la organización:

| Acción                      | Disparador                        |
| --------------------------- | --------------------------------- |
| `user_group.created`        | Grupo creado                      |
| `user_group.updated`        | Nombre o descripción actualizados |
| `user_group.deleted`        | Grupo eliminado                   |
| `user_group.member_added`   | Miembro agregado al grupo         |
| `user_group.member_removed` | Miembro quitado del grupo         |

Una creación con `memberUserUlids` emite un evento `user_group.member_added` por cada miembro
incorporado, además del único evento `user_group.created` — nunca un solo evento que resuma un
conteo. El feed de actividad se lee igual sin importar si un miembro se incorporó al crear el grupo o
se agregó después.

Ver [Eventos de auditoría](https://docs.probara.net/es/developers/api/audit-events/).
