# Bottle Counter Integration

This is the AI-readable integration document for physical bottle counter devices. The human-readable page is available at `/docs/bottle-counter-integration`.

## Summary

Physical counter devices validate startup access through `POST /api/counters/validate-license`, then create count entries through `POST /api/counters/entries`. Startup validation requires both the device OS serial number and the counter license key shown in system management. Count-entry creation uses the counter API key. When counter-entry recording is enabled for the authenticated counter and the request includes a `video/` MIME type, the same response may include a presigned upload target for a raw video file. The device uploads the recording file directly to object storage with that presigned URL, then calls the upload lifecycle endpoint to mark the recording `Uploaded`.

## Endpoint: Validate Counter License

- Method: `POST`
- Path: `/api/counters/validate-license`
- Authentication: OS serial number plus license key in the JSON body
- Request content type: `application/json`
- Response content type: `application/json`

### Request Body

```json
{
  "osSerialNumber": "SN-123456",
  "licenseKey": "ABCDEFGHIJKLMNOP"
}
```

Fields:

- `osSerialNumber`: required counter OS serial number from system management.
- `licenseKey`: required 16-character uppercase alphabetical key from system management.

Successful response:

```json
{
  "valid": true,
  "counter": {
    "id": "counter-uuid",
    "name": "Counter 1",
    "osSerialNumber": "SN-123456"
  }
}
```

If either value is missing, malformed, inactive, or does not match the same active counter, the device must not start.

## Endpoint: Create Counter Entry

- Method: `POST`
- Path: `/api/counters/entries`
- Authentication: counter API key
- Accepted auth headers:
  - `Authorization: Bearer <counterApiKey>`
  - `x-counter-api-key: <counterApiKey>`
- Request content type: `application/json`
- Response content type: `application/json`

### Request Body

```json
{
  "count": 42,
  "videoContentType": "video/mp4"
}
```

Fields:

- `count`: required nonnegative integer.
- `videoContentType`: optional. Include it only when the authenticated counter has `enableCounterEntryRecording` set to `true` and the device wants a presigned upload target for a raw video file.

Accepted `videoContentType` values:

- Any MIME type that starts with `video/`

Common examples:

- `video/mp4`
- `video/webm`
- `video/quicktime`

### Successful Response

```json
{
  "counterEntry": {
    "id": "counter-entry-uuid",
    "createdAt": "2026-04-27T00:00:00.000Z",
    "counterId": "counter-uuid",
    "count": 42,
    "videoFileLocation": "counter-entries/counter-uuid/counter-entry-uuid/video.mp4",
    "videoUploadStatus": "Pending"
  },
  "videoUpload": {
    "url": "https://presigned-upload-url.example",
    "method": "PUT",
    "contentType": "video/mp4"
  },
  "videoUploadError": null
}
```

Response fields:

- `counterEntry`: the persisted counter-entry row. For recording-enabled requests, the route attempts upload signing before inserting this row. If signing fails, the route still inserts and returns the entry with `videoFileLocation: null`.
- `counterEntry.videoUploadStatus`: `Pending` when an upload target has been prepared, `Uploaded` after the device reports that the direct storage upload succeeded, `Failed` after cleanup, and `null` when no recording upload target exists.
- `videoUpload`: `null` when recording is disabled or presigned upload signing fails. Present when the device should upload a recording file.
- `videoUpload.url`: presigned storage URL.
- `videoUpload.method`: currently always `PUT`.
- `videoUpload.contentType`: exact content type the storage upload must use.
- `videoUploadError`: non-null only when the entry was recorded but upload signing failed.

## Upload Flow

1. Send `POST /api/counters/entries` with the count and, when recording is enabled, `videoContentType`.
2. If the HTTP response is not OK, do not upload a recording file. Fix the authentication or validation problem first.
3. If `videoUpload` is `null`, the count entry was still recorded but there is no recording upload target. Do not upload a file for that entry.
4. If `videoUpload` is present, upload the raw video file directly to `videoUpload.url`.
5. After the direct upload succeeds, call `PATCH /api/counters/entries/[counterEntryId]/video-upload` to mark the entry recording `Uploaded`.

Example:

```ts
const createEntryResponse = await fetch('https://your-domain.example/api/counters/entries', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${counterApiKey}`,
  },
  body: JSON.stringify({
    count: countedUnits,
    videoContentType: recordingFile.type,
  }),
});

const entryBody = await createEntryResponse.json();

if (!createEntryResponse.ok) {
  throw new Error(entryBody.error ?? 'Counter entry failed.');
}

if (entryBody.videoUpload) {
  const uploadResponse = await fetch(entryBody.videoUpload.url, {
    method: entryBody.videoUpload.method,
    headers: {
      'Content-Type': entryBody.videoUpload.contentType,
    },
    body: recordingFile,
    credentials: 'omit',
  });

  if (!uploadResponse.ok) {
    throw new Error('Recording upload failed.');
  }

  await fetch(
    `https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
    {
      method: 'PATCH',
      headers: {
        Authorization: `Bearer ${counterApiKey}`,
      },
    },
  );
}
```

The storage `PUT` request is not JSON. It does not use the app session cookie or the counter API key. The `PATCH` request does not upload the file and does not verify object storage; it records the device's confirmation that the preceding direct `PUT` succeeded. If the direct upload fails, retry the same presigned `PUT` while it remains valid instead of creating a duplicate count entry. If the device abandons the recording upload, call the cleanup endpoint so the count entry no longer points at a missing recording.

## Endpoint: Complete Entry Upload

- Method: `PATCH`
- Path: `/api/counters/entries/[counterEntryId]/video-upload`
- Authentication: counter API key
- Accepted auth headers:
  - `Authorization: Bearer <counterApiKey>`
  - `x-counter-api-key: <counterApiKey>`
- Request body: none
- Response content type: `application/json`

Call this endpoint only after the direct storage `PUT` returns success. It keeps `videoFileLocation` unchanged, sets `videoUploadStatus` to `Uploaded`, and returns the updated entry. This is how frontend and management review know the recording is ready.

Successful response:

```json
{
  "counterEntry": {
    "id": "counter-entry-uuid",
    "createdAt": "2026-04-27T00:00:00.000Z",
    "counterId": "counter-uuid",
    "count": 42,
    "videoFileLocation": "counter-entries/counter-uuid/counter-entry-uuid/video.mp4",
    "videoUploadStatus": "Uploaded"
  },
  "videoUpload": null
}
```

```ts
await fetch(
  `https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
  {
    method: 'PATCH',
    headers: {
      Authorization: `Bearer ${counterApiKey}`,
    },
  },
);
```

## Endpoint: Regenerate Entry Upload URL

- Method: `POST`
- Path: `/api/counters/entries/[counterEntryId]/video-upload`
- Authentication: counter API key
- Accepted auth headers:
  - `Authorization: Bearer <counterApiKey>`
  - `x-counter-api-key: <counterApiKey>`
- Request content type: `application/json`
- Response content type: `application/json`

Use this endpoint when the original `videoUpload.url` expires before the device uploads the recording. The route does not create a new count entry. It only returns a fresh presigned `PUT` target for an existing entry that belongs to the authenticated counter and already has a stored `videoFileLocation`.

### Regenerate Request Body

```json
{
  "videoContentType": "video/mp4"
}
```

`videoContentType` is required and follows the same accepted MIME type rules as count-entry creation.

### Regenerate Successful Response

```json
{
  "videoUpload": {
    "url": "https://presigned-upload-url.example",
    "method": "PUT",
    "contentType": "video/mp4"
  }
}
```

Example:

```ts
const regenerateUploadResponse = await fetch(
  `https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${counterApiKey}`,
    },
    body: JSON.stringify({
      videoContentType: recordingFile.type,
    }),
  },
);

const regeneratedUploadBody = await regenerateUploadResponse.json();

if (!regenerateUploadResponse.ok) {
  throw new Error(regeneratedUploadBody.error ?? 'Upload URL regeneration failed.');
}

const regeneratedFileUploadResponse = await fetch(regeneratedUploadBody.videoUpload.url, {
  method: regeneratedUploadBody.videoUpload.method,
  headers: {
    'Content-Type': regeneratedUploadBody.videoUpload.contentType,
  },
  body: recordingFile,
  credentials: 'omit',
});

if (!regeneratedFileUploadResponse.ok) {
  throw new Error('Recording upload failed.');
}

await fetch(
  `https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
  {
    method: 'PATCH',
    headers: {
      Authorization: `Bearer ${counterApiKey}`,
    },
  },
);
```

## Endpoint: Clear Entry Upload Target

- Method: `DELETE`
- Path: `/api/counters/entries/[counterEntryId]/video-upload`
- Authentication: counter API key
- Accepted auth headers:
  - `Authorization: Bearer <counterApiKey>`
  - `x-counter-api-key: <counterApiKey>`
- Response content type: `application/json`

Use `DELETE` when the device gives up on uploading the recording for an entry after a failed direct storage upload. The cleanup route keeps the count entry, deletes the stored object key if present, clears `videoFileLocation`, sets `videoUploadStatus` to `Failed`, and returns the updated entry.

Example:

```ts
await fetch(
  `https://your-domain.example/api/counters/entries/${entryBody.counterEntry.id}/video-upload`,
  {
    method: 'DELETE',
    headers: {
      Authorization: `Bearer ${counterApiKey}`,
    },
  },
);
```

## Count-Only Flow

When counter-entry recording is disabled, omit `videoContentType`.

```ts
await fetch('https://your-domain.example/api/counters/entries', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-counter-api-key': counterApiKey,
  },
  body: JSON.stringify({
    count: countedUnits,
  }),
});
```

The response will include `videoUpload: null` and `videoUploadError: null`.

## Error Handling

Authentication failures return an error response and no count entry is created.

Validation failures return an error response and no count entry is created. Unsupported `videoContentType` values are rejected whenever `videoContentType` is provided.

Upload signing failures are non-fatal. Signing is attempted before persistence, but caught signing failures still return a saved count entry with `videoUpload: null`, `videoUploadError` containing the signing error message, and `videoFileLocation: null`.

Do not call the completion `PATCH` route unless the direct storage `PUT` succeeded. If the direct upload failed and cannot be retried or regenerated, call the cleanup `DELETE` route so the entry is marked `Failed` instead of remaining `Pending`.

## Management Review

Master admins review entries in the management UI.

- Entry list route: `GET /api/counters/[counterId]/entries`
- Query parameters:
  - `fromDate`: optional `YYYY-MM-DD`, UTC day boundary
  - `toDate`: optional `YYYY-MM-DD`, UTC day boundary
- Recording read route: `GET /api/counters/[counterId]/entries/[counterEntryId]/video`

The management list includes `videoUploadStatus`. The read route returns a fresh presigned URL for uploaded recordings after the entry is marked `Uploaded`; legacy entries with a `videoFileLocation` and no status can still be reviewed. Raw video files can be embedded by the management UI. Legacy `.zip` objects, if already present, are opened through the same presigned read route instead of embedded as video.

## Agent Notes

- Use this Markdown file as the canonical machine-readable source.
- Keep the existing `videoContentType` request field name for compatibility.
- Do not send duplicate count entries to retry recording uploads. Retry the presigned `PUT` when possible, regenerate the upload URL if it expired, call `PATCH` only after a successful direct upload, and call `DELETE` when abandoning the recording upload.
- Do not include counter API keys in prompts, logs, or documentation output.
