# GraphQL API

## Endpoint

`POST /graphql` — all queries and mutations go here.

GraphQL Playground is available at `GET /graphql` in development.

## Authentication

All requests must include:

```
Authorization: Bearer <sessionToken>
```

## Queries

### `me: User`
Returns the authenticated user. No extra permission required beyond being logged in.

### `users: [User!]!`
Permission: `users.read`
Returns all users.

### `user(id: ID!): User`
Permission: `users.read`
Returns a single user by ID.

### `roles: [Role!]!`
Permission: `roles.read`
Returns all roles.

### `role(id: ID!): Role`
Permission: `roles.read`
Returns a single role with its permissions.

### `permissions: [Permission!]!`
Permission: `permissions.read`
Returns all available permissions in the system.

### `invitations: [Invitation!]!`
Permission: `invitations.read`
Returns all invitations.

### `auditLogs(input: AuditLogQueryInput): AuditLogResult!`
Permission: `audit.read`
Returns paginated, filterable audit logs. Reading the audit log is itself audited.

### `campaignColors: [CampaignColor!]!`
Permission: `campaigns.read`
Returns reusable campaign colors ordered by most recently used first.

### `campaigns: [Campaign!]!`
Permission: `campaigns.read`
Returns all campaigns.

Campaigns now store their own `title`, `vintage`, and `color` directly, while `product` remains an optional relation.

### `campaign(id: ID!): Campaign`
Permission: `campaigns.read`
Returns a single campaign by ID.

### Connection search query strings

`products`, `customers`, and `orders` support the `query` argument.
Syntax: `/docs/llms/search-query-syntax.md`.
Generated field list: `/docs/llms/search-query-fields.md`.
Use `includeTotalCount: true` only when the caller needs `totalCount`.

### `searchConfig(type: String!): SearchConfig!`
Permission: collection-specific read permission, for example `products.read` for `type: "products"`.

Returns frontend search configuration for a supported Typesense-backed
collection.

For `type: "products"`, the response includes:

- The concrete Typesense collection name
- A short-lived scoped API key for direct browser search requests
- The Typesense nodes the frontend should connect to
- A `supportsFreeTextSearch` boolean that tells the frontend whether to show a free-text search input
- A `columns` array describing the dynamic table shape, including each column's name, title, datatype, and search / filter / sort capabilities
- Allowed search, filter, sort, and facet fields
- Default sorting and page size options

`columns[].dataType` is frontend-oriented and can differ from the raw Typesense
field type when the UI needs richer rendering semantics.

Current special values:

- `timestamp` for Unix timestamp integers such as `createdAt` and `updatedAt`
- `hexColor` for color values such as `wineTypeColor`

In normal runtime the collection name is `products`.
That name should be treated as a stable frontend contract, even when the
backend rebuilds the underlying Typesense collection after schema changes.
During isolated e2e runs using `X-Test-run-id`, the backend returns a scoped
collection such as `products_e2e_frontend_run_alpha` instead.

Example:

```graphql
query GetProductSearchConfig {
  searchConfig(type: "products") {
    collectionName
    scopedApiKey
    expiresAt
    nodes { host port protocol }
    supportsFreeTextSearch
    columns {
      name
      title
      dataType
      searchable
      filterable
      sortable
    }
    searchableFields
    filterableFields
    sortableFields
    facetFields
    defaultSort
    pageSizeOptions
  }
}
```

### `searchCollections: [SearchCollectionSummary!]!`
Permission: authenticated user; results are filtered by the caller's role permissions.

Returns lightweight metadata for every Typesense collection the caller has
access to. No API key is included — use this for discovery (e.g. populating
a global search dropdown or navigation).

A collection is included only when the caller holds the corresponding read
permission (e.g. `products.read` for the products collection).

Example:

```graphql
query ListMyCollections {
  searchCollections {
    collectionName
    displayName
    searchableFields
    filterableFields
    sortableFields
    facetFields
    defaultSort
    pageSizeOptions
  }
}
```

### `globalSearchConfig: GlobalSearchConfig!`
Permission: authenticated user; scoped to the caller's permitted collections.

Returns a single search-only Typesense API key restricted to the collections
the caller is allowed to search, plus per-collection metadata that the
frontend can use directly to build a `multi_search` request.

If the caller has no search permissions, `searches` is empty and
`scopedApiKey` is an empty string.

Example query:

```graphql
query GetGlobalSearch {
  globalSearchConfig {
    scopedApiKey
    expiresAt
    nodes { host port protocol }
    searches {
      type
      label
      collection
      queryBy
      perPage
      enabled
    }
  }
}
```

Example response:

```json
{
  "data": {
    "globalSearchConfig": {
      "scopedApiKey": "<scoped-key>",
      "expiresAt": "2026-03-18T13:10:00.000Z",
      "nodes": [{ "host": "localhost", "port": 8108, "protocol": "http" }],
      "searches": [
        {
          "type": "customers",
          "label": "Kunder",
          "collection": "customers",
          "queryBy": ["firstName", "middleName", "lastName", "email", "phone", "company"],
          "perPage": 5,
          "enabled": true
        },
        {
          "type": "products",
          "label": "Products",
          "collection": "products",
          "queryBy": ["title", "vismaProductNumber", "productTypeTitle", "wineTypeTitle"],
          "perPage": 5,
          "enabled": true
        }
      ]
    }
  }
}
```

The frontend builds the `multi_search` request directly with the returned key:

```json
{
  "searches": [
    {
      "collection": "customers",
      "q": "casper",
      "query_by": "firstName,middleName,lastName,email,phone,company",
      "per_page": 5
    },
    {
      "collection": "products",
      "q": "casper",
      "query_by": "title,vismaProductNumber,productTypeTitle,wineTypeTitle",
      "per_page": 5
    }
  ]
}
```

## Mutations

### `bootstrapFirstUser(email: String!): AuthPayload!`
No authentication required.
Only works when the system has zero users.

Creates the first user in the environment and assigns the Superadmin role.
Useful for local bootstrap and clean-environment tests.

### `devLogin(email: String!): AuthPayload!`
No authentication required.
Available outside production only.

Creates a session token for an existing active user without using OAuth.
This is intended for local development, frontend e2e tests, and backend e2e tests.

Example:

```graphql
mutation DevLogin($email: String!) {
  devLogin(email: $email) {
    token
    user {
      id
      email
    }
  }
}
```

### `cleanupTestRun: Boolean!`
No authentication model is enforced by the schema itself, but the request must include a valid `X-Test-run-id` header.
Available only when `ENABLE_E2E_TEST_MODE=true`.

Drops the isolated PostgreSQL schema and deletes the isolated Typesense
collection for the current frontend e2e run.

This is intended for automated frontend cleanup after suites using request-scoped isolation.

Example:

```graphql
mutation CleanupCurrentTestRun {
  cleanupTestRun
}
```

Typical request headers for this mutation:

```http
Authorization: Bearer <sessionToken>
X-Test-run-id: frontend_run_alpha
```

### `createInvitation(input: CreateInvitationInput!): Invitation!`
Permission: `invitations.create`
Creates a new user (inactive) and a pending invitation. The user activates on first OAuth login.

Input:
```graphql
input CreateInvitationInput {
  email: String!
  roleId: ID!
}
```

### `activateUser(userId: ID!): User!`
Permission: `users.update`
Manually activates an existing user.

### `deactivateUser(userId: ID!): User!`
Permission: `users.update`
Deactivates a user. Their sessions will stop working.

### `createRole(input: CreateRoleInput!): Role!`
Permission: `roles.create`

Input:
```graphql
input CreateRoleInput {
  name: String!
  description: String
  permissionKeys: [String!]!
}
```

### `updateRole(id: ID!, input: UpdateRoleInput!): Role!`
Permission: `roles.update`
Cannot update system roles (Superadmin). Returns error if attempted.

### `deleteRole(id: ID!): Boolean!`
Permission: `roles.delete`
Cannot delete system roles. Returns error if attempted.

### `logout: Boolean!`
Requires authentication. Logs out the current session.

### `createCampaign(input: CreateCampaignInput!): Campaign!`
Permission: `campaigns.create`

Creates a campaign with a required campaign-owned `title` and optional `productId`, `vintage`, and `color`.

If `productId` is supplied and `vintage` or `color` are omitted, the backend may prefill them from the linked product and its wine type.

Example:

```graphql
mutation CreateCampaign($input: CreateCampaignInput!) {
  createCampaign(input: $input) {
    id
    title
    vintage
    color
    product { id title }
  }
}
```

### `updateCampaign(id: ID!, input: UpdateCampaignInput!): Campaign!`
Permission: `campaigns.update`

Updates any campaign-owned fields and may also detach the product relation by sending `productId: null`.

### `deleteCampaign(id: ID!): Boolean!`
Permission: `campaigns.delete`
Deletes a campaign.

### `deleteAllCampaigns: Int!`
Permission: `campaigns.delete`
Deletes all campaigns and returns the number of deleted rows.

## Types

```graphql
type User {
  id: ID!
  email: String!
  name: String
  avatarUrl: String
  isActive: Boolean!
  roles: [Role!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Role {
  id: ID!
  name: String!
  description: String
  isSystem: Boolean!
  permissions: [Permission!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Permission {
  id: ID!
  key: String!
  description: String
}

type Invitation {
  id: ID!
  email: String!
  status: String!       # "pending" | "accepted" | "expired"
  roleId: ID!
  expiresAt: DateTime!
  createdAt: DateTime!
}

type AuditLog {
  id: ID!
  actorUserId: ID
  operation: String!    # "CREATE" | "READ" | "UPDATE" | "DELETE"
  entityType: String!
  entityId: String
  correlationId: String
  ipAddress: String
  userAgent: String
  before: JSON
  after: JSON
  metadata: JSON
  createdAt: DateTime!
}

type AuditLogResult {
  items: [AuditLog!]!
  total: Int!
  limit: Int!
  offset: Int!
}

type CampaignColor {
  color: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Campaign {
  id: ID!
  product: Product
  title: String!
  vintage: Int
  color: String
  note: String
  price: String!
  author: User!
  medium: CampaignMedium!
  scheduledAt: DateTime!
  status: CampaignStatus!
  createdAt: DateTime!
  updatedAt: DateTime!
}

input CreateCampaignInput {
  productId: ID
  title: String!
  vintage: Int
  color: String
  note: String
  price: String!
  authorUserId: ID!
  medium: CampaignMedium!
  scheduledAt: DateTime!
  status: CampaignStatus
}

input UpdateCampaignInput {
  productId: ID
  title: String
  vintage: Int
  color: String
  note: String
  price: String
  authorUserId: ID
  medium: CampaignMedium
  scheduledAt: DateTime
  status: CampaignStatus
}
```

## Filtering Audit Logs

```graphql
input AuditLogQueryInput {
  actorUserId: ID
  entityType: String
  entityId: String
  operation: String
  correlationId: String
  from: DateTime
  to: DateTime
  limit: Int          # Default: 50, max: 200
  offset: Int         # Default: 0
}
```

## Error Shape

GraphQL errors follow the standard `errors` array format:

```json
{
  "errors": [
    {
      "message": "Missing required permission: users.read",
      "locations": [...],
      "path": [...]
    }
  ],
  "data": null
}
```

See [errors.md](errors.md) for the full list of error messages and codes.

For frontend implementation details and form behavior, see [campaigns.md](campaigns.md).
