# Connected Apps API 參考

本頁是應用管理金鑰的 `/api/apps/v1` 合約。新流程填表使用 [流程接入 API](workflow-ui.md)，不是本頁的普通 responses；流程資料副本另見[同步 API](workflow-sync.md)。新流程正式答案固定由 OpenDashboard 管理。

同一套 API 可用於直接保存到 OpenDashboard、由網站讀回所屬應用資料，或由網站保留本地資料並同步給 OpenDashboard。保存方式及介面可獨立選擇。現有 API 及個人報告連結可直接使用；普通表單 API 不包含通用嵌入元件或自動雙向同步。流程已有連結／iframe 及單向資料變更通知，使用前述獨立契約。

瀏覽器透過你自己的後端操作；後端核對使用者對 respondent 的存取權，應用金鑰只保護應用範圍，不代替訪客登入。直接保存模式成功回傳 id／version 後才顯示提交成功。雙份保存需由網站選定主要寫入端、安排待同步工作及處理衝突，不能把本地保存成功當作 OpenDashboard 已接收。

通用網站接入契約。所有範例均為合成資料。路徑以 `/api/apps/v1` 開始，主機使用你的 OpenDashboard 部署，例如 `https://registration.konnec.ai`。本頁描述同版程式的能力；部署需包含相應資料庫變更，請先在管理員提供的測試環境驗證。

設計網站自己的填表頁，見[自己的填表介面教程](custom-ui.md)，內有可下載定義、答案及前後端分工範例。

## 驗證、格式及限制

```http
Authorization: Bearer <SERVER_SIDE_APP_KEY>
Content-Type: application/json
```

JSON 欄位採 snake_case。JSON 請求最多 256 KB；每應用正式及測試合計 600 次／10 分鐘。金鑰只能由伺服器使用，不能送到瀏覽器。應用隔離及 test/live 隔離適用於所有資源；外來資源回 404，不透露存在與否。

錯誤格式：

```json
{
  "ok": false, "error": "invalid_answers",
  "message": "The response was not saved. Fix these and send it again.",
  "fields": { "participants.0.category": "Unknown choice." }
}
```

某些定義驗證另帶 `errors` 陣列。以 `error` 判斷程式分支，用 `fields`／`errors` 協助更正，不要只依英文 message。

## 應用資訊

`GET /me` → `200 { "app": { "id": "demo-app", "name": "Demo app" }, "test": true }`。

`GET /me?capabilities=true` 額外帶 `capabilities: { "identified_mode": false }`。預設呼叫保持原格式。identified mode 由超級管理員設定，才可宣告身份欄位及保存私人附件。

## 表單定義

`POST /forms` → `201 { "id": "evt_…" }`。普通應用表單是 survey，由來源網站顯示，沒有公開 OpenDashboard 填表頁。正文為 `title`（必填，最多 200 字）、`description`（選填，最多 2000 字）、`questions`（最多 50 題）。建立不是冪等操作。

```json
{
  "title": "Demo survey",
  "questions": [
    { "key": "category", "title": "類別", "type": "short_text", "personal": false },
    { "key": "rating", "title": "評分", "type": "number", "min": 0, "max": 10 }
  ]
}
```

每題 key 為 1–40 個小寫英文字、數字或 `_`，在表單內唯一。`required: true` 為必答，否則選填。不能提供 quiz 答案；來源應用自行評分。

| type | 答案及設定 |
| --- | --- |
| hidden | 文字最多 500 字；數字轉文字，永不進報表。 |
| short_text / paragraph | 短答最多 500；長答預設 5,000，可用 `max_length` 設為 1–100,000 整數。計數為 UTF-16 字元，超長拒絕，不截斷。 |
| number | 有限 JSON 數字；可選 min、max、integer、unit，0 保留。 |
| date | YYYY-MM-DD。 |
| single_choice / dropdown | 一個 options 值；options 2–30 個。 |
| multiple_choice | options 值的陣列。 |
| app_result | 以 fields 宣告的結果物件。 |
| repeating_group | 以 fields 宣告的物件陣列。 |
| section / text_block | 純排版，不接收答案。 |
| email / phone / file_upload | identified mode 專用；永遠 personal。電郵轉小寫；電話最多 40 字；檔案為 HTTPS URL，最多 2000 字，URL 本身不會把檔案複製到 OpenDashboard。 |

`app_result` 每題 1–30 fields，型別 number／text／labels；text 最多 500 字，labels 最多 50 個非空字串、每個最多 40 字。欄位可設 required，未填選填欄位為 null，未知 key 是錯誤。

## 重複群組及個人分類

群組宣告整數 `0 <= min <= max <= 100`，每層最多 50 fields。可巢狀一層，不能再深。答案物件按 key 對應，未知 key 拒絕。fields 可用 text／short_text、paragraph、number、date、選擇題、labels 及 identified mode 的身份欄位；選擇欄位可設 min_selected／max_selected。

```json
{
  "key": "participants", "title": "參加者", "type": "repeating_group",
  "min": 0, "max": 100, "personal": false,
  "fields": [
    { "key": "category", "label": "類別", "type": "text", "personal": false },
    { "key": "topics", "label": "主題", "type": "multiple_choice", "options": ["A", "B"], "personal": false }
  ]
}
```

答案例：`{ "participants": [{ "category": "Demo", "topics": ["A", "B"] }] }`。正式回覆檢查群組最少數量及所有必填項目。

新定義的文字、hidden、labels 及群組預設 personal；數字、日期、選擇題預設非個人。電郵、電話、檔案永遠 personal。群組包含任何 personal 子欄位時，合集報表排除整個群組。早期沒有旗標的定義保留既有分類；改旗標前核對報表影響。只有安全的非識別文字才能明確標 `personal: false`。

保留題目 key：respondent、form_id、form_title、form_position、submitted_at、items_answered 及 avg_ 開頭。保留 app_result field key：respondent、form_id、form_title、form_position、item_key、item_title、item_position、submitted_at。

`GET /forms/:id` → 200，帶 id、title、description、test、created_at、questions，以及補齊的預設值。

`PATCH /forms/:id/fields` 只改 personal 分類，不能改型別或欄位結構。1–200 項，全成功或全失敗。群組內使用點分 field 路徑。

```json
{ "fields": [
  { "question": "category", "personal": false },
  { "question": "participants", "field": "category", "personal": false }
] }
```

→ 200，傳回完整表單。分類在下次合集讀取、工作區刷新及個人報告開啟生效；已凍結分享／快照不重寫。

## 提交、取代及讀取回覆

`POST /forms/:id/responses` → 201，傳回 `{ "id": "reg_…", "version": 1 }`。

```json
{
  "respondent": "demo-contact-a", "answers": { "category": "Demo", "rating": 8 },
  "submitted_at": "2026-01-01T00:00:00Z", "replace": true, "expected_version": 0
}
```

respondent 為 4–64 個英文字、數字、`_` 或 `-`；使用不含個人身份的穩定來源編號。每表單每 respondent 只有一份回覆。answers 以題目 key 為鍵，整份驗證，錯一欄整份不保存。

submitted_at 選填，預設現在；有 expected_version 時必須明確提供固定時間。ISO 時間不能超過現在 5 分鐘。第二次提交需 replace true，成功保留 id、version 加 1。

expected_version 選填，非負安全整數；0 表示期望不存在。只有目前版本吻合才寫入。已儲存版本等於 expected＋1，且正規化答案和時間完全相同時，精確重試回 200 原 id／version，不再次寫入。其他過時版本回 409 response_changed。沒有 expected_version 的 replace 保持舊行為，重送仍可能加版本。

`GET /forms/:id/responses` → `200 { "responses": [...] }`，最早提交在前。每項含 id、respondent、version、submitted_at、answers，包括私人答案，因此只能由應用後端使用，不可當公開報表輸出。可加 `?respondent=demo-7f3a9c` 只讀取該回覆者（不存在時為空陣列），服務端會先檢查應用／TEST 或 LIVE 歸屬再精確篩選；無效或重複參數回 `400 invalid_respondent`。不加參數仍讀取全部；這個端點沒有 cursor 分頁。

## 合集及資料表

`POST /collections` → `201 { "id": "col_…" }`。title 必填，description 選填，form_ids 1–50 個唯一同應用、同模式的表單，按次序列出。

```json
{ "title": "Demo collection", "form_ids": ["<FORM_ID>"] }
```

相同報表欄位 key 跨表單型別不一致會拒絕。合集不可修改；不同成員需新建。建立不是冪等操作。

`GET /collections/:id` → 200，帶 id、title、description、test 及 forms（id、title、position）。

`GET /collections/:id/tables?respondent=<ref>` → `200 { "tables": { ... } }`。respondent 選填；指定時資料庫只讀該人的行。每個表含 columns、rows；columns 含 key、type（string／number／date）、role（dimension／measure）、description。空表保留字典。

| 表／unit | 一行代表甚麼 | 計數含義 |
| --- | --- | --- |
| submissions | 一位回覆者在一份表單的回覆 | 提交份數。 |
| items | 一個已答 app_result 題目 | 題目數，不是重複群組。 |
| labels | app_result 中一個非個人標籤 | 標籤次數。 |
| groups:…／項目 | 安全群組中的一個項目 | 群組項目數。 |
| groups:…／選項 | 群組選擇欄位的一個已選選項 | 選項次數；可多於項目數。 |

固定上下文含 respondent、form_id、form_title、form_position、submitted_at。submissions 另帶 items_answered、app_result 數值的 avg_ 欄位及安全頂層簡答／單選／下拉／數字。items 帶 item_key、item_title、item_position 及安全 number／text 結果。labels 帶 field_key、label。hidden 和個人答案不輸出。

群組表鍵是不透明字串，使用回傳 label、unit、description 及字典，不要自行拼接。帶 response_key、item_key 及巢狀 parent_item_key；選項表帶 choice。父層安全文字維度可附為 parent:key，父層數值不重複。每份表單最多展開 250,000 項目／選項，完整合集最多 4 MiB，超限回 413 collection_tables_too_large，沒有部分成功資料。

## 個人報告連結

管理員需先在應用工作區建立儀表板，指定 Page.template，例如 workshop_report。API 使用範本名稱，不使用 page id。

`POST /collections/:id/report-links`：

```json
{ "respondent": "demo-contact-a", "template": "workshop_report", "expires_in_days": 30 }
```

→ 201 `{ "id": "…", "url": "https://registration.konnec.ai/dashboard/r/<id>", "expires_at": "…" }`。template 1–40 個小寫英文字、數字、`_`；expires_in_days 整數 1–3650，預設 365。同一 respondent／template 仍有效時傳回原連結 200；過期或停用後建立新連結 201。

每次開啟重新按 respondent 讀合集及應用記錄。手動來源／固定文字對所有人相同，範本不可含全體個人資料。staff-only 或 live operational 範本不能做個人報告。持有 URL 可查看，不必登入；不要公開 URL。

`DELETE /report-links/:id` → `200 { "id": "…", "revoked_at": "…" }`，立即停用，重複停用同結果。應用停用、範本刪除／改名、過期也使連結失效。分享快照 `/dashboard/s/...` 是另一功能。

## 歷史記錄 Records

responses 保存最新答案；records 是不能取代的歷史。每筆包含 kind、schema、summary 和私人 data。summary 是經驗證的報表欄位；data 是任意 JSON，永不進入資料表、模板、分享或報告。

`POST /record-kinds` → 201；相同聲明重送為 200，不同聲明同 kind／schema 回 409。

```json
{
  "kind": "practice", "schema": "demo.practice/1",
  "summary_fields": [
    { "key": "score", "type": "number", "min": 0, "max": 100 },
    { "key": "category", "type": "text", "personal": false },
    { "key": "tasks", "type": "list", "personal": false, "fields": [
      { "key": "category", "type": "text", "personal": false },
      { "key": "score", "type": "number" }
    ] }
  ]
}
```

kind 1–40 小寫字、數字、`_`；schema 1–100 英文字、數字、`_ . : / -`，首字為英文字或數字。summary_fields 1–30 欄，number／text／labels 規則同 app_result；list 最多 100 物件，內含 1–30 個葉欄位，不能巢狀 list。保留 key：respondent、record_id、kind、record_kind、record_schema、recorded_at、is_test、deleted_at、list_key、item_position。

`GET /record-kinds?kind=` → `{ "record_kinds": [...] }`，依 kind、schema 排序。改聲明需新 schema，不修改舊記錄。

`POST /records` → 201 `{ "id": "rec_…", "record_id": "demo-attempt-1", "recorded_at": "…" }`。

```json
{
  "record_id": "demo-attempt-1", "respondent": "demo-contact-a",
  "kind": "practice", "schema": "demo.practice/1",
  "recorded_at": "2026-01-01T00:00:00Z",
  "summary": { "score": 80, "category": "Demo", "tasks": [] },
  "data": { "private_notes": "Synthetic notes only" }
}
```

record_id 必填，1–128 英文字、數字、`_ . : -`，同應用／模式永久唯一。recorded_at 必填，不能超過現在 5 分鐘；respondent 規則同回覆。summary 按聲明全份驗證；data 選填，預設 null。compact UTF-8 JSON 大小：summary ≤8192 bytes、data ≤65,536 bytes。

固定 record_id 精確重送傳 200 原結果；內容不同為 409 record_exists；刪除過為 409 record_deleted，不可重用。JSON 物件 key 次序／空白不影響相同判斷；陣列順序及正規化內容有影響。

`GET /records?respondent=&kind=&since=&limit=&cursor=&include_deleted=` → records、next_cursor。最早 recorded_at 然後 id 排序；since inclusive；limit 1–500，預設 100。保持相同篩選及帶 next_cursor 取下頁，到 null 為止。晚到但時間較早的記錄可能需要另一次讀取。include_deleted=true 會包含 tombstone，其 summary／data 為 null。

工作區的「應用記錄」產生 records／record_items 長表；只含安全 summary 數值、明確非個人文字及非個人 list，labels 不展開。data 永不進報表，不同 schema 欄位型別衝突時該欄不輸出。

`DELETE /records?respondent=<ref>` → `{ "respondent": "demo-contact-a", "deleted": 2 }`。清除所有該人記錄 summary／data，但永久保留識別及刪除時間；不刪表單答案、附件或凍結分享／快照。

## 私人附件 Uploads

identified mode 專用。metadata 和檔案均私有，傳給應用後端的下載 URL 只有 60 秒。這不是公開表單上傳頁。

`POST /uploads` → 200，必填 external_id、name、size、content_type、sha256。

```json
{
  "external_id": "demo-file-1", "name": "synthetic.txt", "size": 5,
  "content_type": "text/plain",
  "sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
}
```

這個摘要對應 UTF-8 原文 hello（沒有換行）。external_id 1–128 英文字、數字、`_ . : -`；name 非空最多 200 字；size 為 1 byte 至 50 MiB 的整數；sha256 為 64 個小寫十六進制。content_type 是合法 MIME type，最多 150 字。

未完成時回 id、metadata、completed false、upload_url 及 token。相同 external_id／metadata 可重試；不同內容回 409 upload_exists。保存 id，不記錄 signed URL／token。

```js
// reservation 是 POST /uploads 的 JSON；bytes 是相同五個 UTF-8 bytes。
if (!reservation.completed) {
  const put = await fetch(reservation.upload_url, {
    method: 'PUT',
    headers: { 'Content-Type': 'text/plain', 'x-upsert': 'false' },
    body: new TextEncoder().encode('hello')
  });
  if (!put.ok) throw new Error('Upload outcome requires reconciliation');
}
```

只把檔案 bytes 送到 signed URL，不能把 OpenDashboard app key 轉寄給儲存服務。`POST /uploads/:id` 驗證實際 bytes 的大小／SHA-256 後標 completed，重試為 200。未上傳回 409 upload_not_ready；大小／摘要不符回 upload_content_mismatch，不能跳過校驗。

`GET /uploads/:id` → 完成 metadata、url、expires_in 60，private/no-store。未完成不能下載，外來應用／模式 id 回 404。私人引用保存於私人 data，不放儀表板。現有 uploads 沒有刪除端點；record 刪除不連帶抹除附件，保留／清除需另由管理員處理。

## 錯誤及重試速查

| HTTP | error | 處理 |
| --- | --- | --- |
| 400 | invalid_json / invalid_body / invalid_form / invalid_answers / invalid_fields / invalid_collection | 按 fields 或 errors 更正，整份重新送。 |
| 400 | invalid_respondent / invalid_query / invalid_record_kind / invalid_record / summary_invalid / invalid_report_link / invalid_upload | 更正識別、聲明或限制。 |
| 400 | identified_mode_required | 聯絡管理員確認身份模式。 |
| 401 | invalid_app_key / app_disabled | 核對金鑰或應用狀態，不盲目重試。 |
| 404 | form_not_found / collection_not_found / record_kind_unknown / upload_not_found / report_link_not_found / template_not_found | 核對應用、模式和 id／範本設定。 |
| 409 | already_submitted / response_changed | 讀取最新回覆，處理取代或版本衝突。 |
| 409 | record_kind_conflict / record_exists / record_deleted / upload_exists | 使用正確聲明／相同內容，或新識別，不覆寫不可變資料。 |
| 409 | upload_not_ready / upload_content_mismatch | 核對 bytes／傳輸結果，再完成上傳。 |
| 413 | request_body_too_large / record_too_large / collection_tables_too_large | 縮減或分拆資料，不接受部分結果。 |
| 429 | rate_limited | 等 Retry-After 秒後以相同冪等內容有限次重試。 |
| 500 | internal_error | GET 可重讀；寫入遵守各端點冪等規則並查核未知結果。 |

報告設定另可能回 400 template_not_for_collection／template_staff_only。參考上面的報告條件。表單／合集建立沒有冪等鍵；回覆、record、upload、report link 各有自己的重試規則，不應共用一個「所有 POST 都重送」機制。

## 原站回寫 Intakes 的適用範圍

`/intakes`、`/intake-commands` 及確認端點用於原站保存正式答案時的指令回寫，見[完整契約與例子](intakes.md)。專用簽名通知只包含待處理指令的 generation，不代表普通 responses 具有通用變更 webhook。OpenDashboard 中央保存不需要原站確認；目前 intake 沒有切換中央保存的 direct 參數。
