From d97a3c71539e868328e4f5fa8501819a3e817660 Mon Sep 17 00:00:00 2001 From: eigger Date: Mon, 5 Oct 2026 19:49:10 +0900 Subject: [PATCH] feat: expose detailed state summary for read integrations --- apps/api/src/routes/auth.ts | 1 + apps/api/src/routes/states.ts | 10 +- apps/api/src/test/stateRoutes.test.ts | 5 +- apps/web/app/globals.css | 50 ++++++++ apps/web/app/settings/page.tsx | 2 + apps/web/components/ApiTokenManager.tsx | 156 ++++++++++++++++++++++++ apps/web/lib/i18n/translations.ts | 22 +++- apps/web/lib/types.ts | 2 + docs/WORKLOG.md | 12 ++ docs/WORKPLAN.md | 2 +- docs/api.md | 14 ++- 11 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 apps/web/components/ApiTokenManager.tsx diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 7231a9f..be95d8f 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -157,6 +157,7 @@ export async function authRoutes(app: FastifyInstance) { email: user.email, role: user.role, householdId, + householdRole: request.householdRole, needsPet, }; }); diff --git a/apps/api/src/routes/states.ts b/apps/api/src/routes/states.ts index 6dd171e..3b9ebae 100644 --- a/apps/api/src/routes/states.ts +++ b/apps/api/src/routes/states.ts @@ -3,6 +3,7 @@ import { prisma } from "../lib/prisma.js"; import { t } from "../lib/i18n.js"; import { householdWhere } from "../lib/householdScope.js"; import { petStateFor } from "../lib/petState.js"; +import { todaySummaryForPet } from "../lib/todaySummary.js"; import { requireStateReadAccess, resolveTokenScopedField, @@ -49,14 +50,19 @@ export async function stateRoutes(app: FastifyInstance) { return reply.code(404).send({ error: t("petNotFound", request.locale) }); } - const state = await petStateFor(prisma, { householdId, petId }); + const [state, todaySummary] = await Promise.all([ + petStateFor(prisma, { householdId, petId }), + todaySummaryForPet(prisma, householdId, petId), + ]); if (!state) return reply.code(404).send({ error: t("petNotFound", request.locale) }); if (request.authMethod === "apiToken" && request.apiTokenContext) { void touchApiTokenLastUsed(request.apiTokenContext.id); } - return state; + // `today` stays as the compact, backwards-compatible view. `todaySummary` + // adds unit-separated totals and last recorded values for richer clients. + return { ...state, todaySummary }; }, ); } diff --git a/apps/api/src/test/stateRoutes.test.ts b/apps/api/src/test/stateRoutes.test.ts index 632735d..f9e3f8d 100644 --- a/apps/api/src/test/stateRoutes.test.ts +++ b/apps/api/src/test/stateRoutes.test.ts @@ -82,7 +82,10 @@ describe("GET /api/states — 역방향 읽기 (WORKPLAN P2-04)", () => { headers: { authorization: `Bearer ${jwt(app)}` }, }); expect(res.statusCode).toBe(200); - expect(res.json()).toMatchObject({ pet: { id: PET, name: "콩" } }); + expect(res.json()).toMatchObject({ + pet: { id: PET, name: "콩" }, + todaySummary: [], + }); }); // 기존 토큰은 event:create만 갖고 있다 — 배포만으로 읽기 권한이 생기면 안 된다. diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 956006e..046effa 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -4244,6 +4244,56 @@ th { margin-top: 6px; } +.api-token-manager { + margin-top: 12px; +} + +.api-token-manager h2 { + margin-top: 0; + font-size: 1rem; +} + +.api-token-issued { + display: grid; + gap: 8px; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--color-warning-border); + border-radius: 8px; +} + +.api-token-issued p { + margin: 0; +} + +.api-token-issued code { + padding: 8px; + border-radius: 6px; + background: var(--color-surface-hover); + overflow-wrap: anywhere; + user-select: all; +} + +.api-token-list { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.api-token-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 10px; + border-top: 1px solid var(--color-border); +} + +.api-token-row p { + margin: 4px 0 0; + overflow-wrap: anywhere; +} + .api-explorer-inline-code { padding: 2px 6px; border-radius: 4px; diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 0f2b189..045edd7 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -10,6 +10,7 @@ import { ThemeToggle } from "../../components/ThemeToggle"; import { AccentColorToggle } from "../../components/AccentColorToggle"; import { LanguageToggle } from "../../components/LanguageToggle"; import { PushNotificationSettings } from "../../components/PushNotificationSettings"; +import { ApiTokenManager } from "../../components/ApiTokenManager"; export default function SettingsPage() { const router = useRouter(); @@ -124,6 +125,7 @@ export default function SettingsPage() { + ); } diff --git a/apps/web/components/ApiTokenManager.tsx b/apps/web/components/ApiTokenManager.tsx new file mode 100644 index 0000000..a3ce4a5 --- /dev/null +++ b/apps/web/components/ApiTokenManager.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { apiFetch } from "../lib/api"; +import { useLocale } from "../lib/i18n/locale-context"; +import type { Pet } from "../lib/types"; + +type ApiToken = { + id: string; + name: string; + scopes: string[]; + petId: string | null; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + pet?: { name: string } | null; +}; + +type IssuedToken = ApiToken & { token: string }; + +/** Manage household API tokens; the API itself gates this section to OWNERs. */ +export function ApiTokenManager({ isHouseholdOwner }: { isHouseholdOwner: boolean }) { + const { t } = useLocale(); + const [pets, setPets] = useState([]); + const [petId, setPetId] = useState(""); + const [tokens, setTokens] = useState([]); + const [issuedToken, setIssuedToken] = useState(null); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const load = useCallback(async () => { + if (!isHouseholdOwner) return; + try { + const tokenResponse = await apiFetch("/api/tokens"); + if (!tokenResponse.ok) throw new Error(String(tokenResponse.status)); + const petResponse = await apiFetch("/api/pets"); + if (!petResponse.ok) throw new Error(String(petResponse.status)); + const [tokenRows, petRows] = await Promise.all([ + tokenResponse.json() as Promise, + petResponse.json() as Promise, + ]); + setTokens(tokenRows); + setPets(petRows); + setPetId((current) => current || petRows[0]?.id || ""); + } catch { + setMessage(t("apiTokenLoadError")); + } + }, [isHouseholdOwner, t]); + + useEffect(() => { + void load(); + }, [load]); + + const createToken = useCallback(async () => { + if (!petId) return; + setBusy(true); + setIssuedToken(null); + setMessage(null); + try { + const pet = pets.find((row) => row.id === petId); + const response = await apiFetch("/api/tokens", { + method: "POST", + body: JSON.stringify({ + name: `Home Assistant · ${pet?.name ?? "Kibble"}`, + scopes: ["state:read"], + petId, + }), + }); + if (!response.ok) throw new Error(String(response.status)); + setIssuedToken((await response.json()) as IssuedToken); + setMessage(t("apiTokenCreatedCopyNow")); + await load(); + } catch (err) { + setMessage(t("apiTokenCreateError", { status: err instanceof Error ? err.message : "" })); + } finally { + setBusy(false); + } + }, [load, petId, pets, t]); + + const revokeToken = useCallback(async (token: ApiToken) => { + if (!confirm(t("apiTokenRevokeConfirm", { name: token.name }))) return; + setBusy(true); + setMessage(null); + try { + const response = await apiFetch(`/api/tokens/${encodeURIComponent(token.id)}`, { + method: "DELETE", + }); + if (!response.ok) throw new Error(String(response.status)); + if (issuedToken?.id === token.id) setIssuedToken(null); + await load(); + setMessage(t("apiTokenRevoked")); + } catch (err) { + setMessage(t("apiTokenRevokeError", { status: err instanceof Error ? err.message : "" })); + } finally { + setBusy(false); + } + }, [issuedToken, load, t]); + + const copyToken = useCallback(async () => { + if (!issuedToken) return; + try { + await navigator.clipboard.writeText(issuedToken.token); + setMessage(t("apiTokenCopied")); + } catch { + setMessage(t("apiTokenSelectToCopy")); + } + }, [issuedToken, t]); + + if (!isHouseholdOwner) return null; + + return ( +
+

{t("apiTokenManagerTitle")}

+

{t("apiTokenManagerHint")}

+ {pets.length > 0 ? ( + + ) : ( +

{t("apiExplorerNoPets")}

+ )} + + {message &&

{message}

} + {issuedToken && ( +
+

{t("apiTokenOneTimeWarning")}

+ {issuedToken.token} + +
+ )} +
+ {tokens.map((token) => ( +
+
+ {token.name} +

+ {token.pet?.name ?? t("apiTokenAnyPet")} · {token.scopes.join(", ")} · {t("apiTokenLastUsed")}: {token.lastUsedAt ?? t("neverLabel")} +

+
+ +
+ ))} + {tokens.length === 0 &&

{t("apiTokenNone")}

} +
+
+ ); +} diff --git a/apps/web/lib/i18n/translations.ts b/apps/web/lib/i18n/translations.ts index 14609d4..55cdd5b 100644 --- a/apps/web/lib/i18n/translations.ts +++ b/apps/web/lib/i18n/translations.ts @@ -377,6 +377,27 @@ const dict = { en: "No pets yet — endpoints that need one cannot run.", }, apiExplorerNeedsPet: { ko: "반려동물을 먼저 고르세요", en: "Pick a pet first" }, + apiTokenManagerTitle: { ko: "외부 연동 토큰", en: "Integration tokens" }, + apiTokenManagerHint: { + ko: "Home Assistant용 읽기 전용 토큰을 선택한 반려동물에 고정해 발급합니다. 원문은 발급할 때 한 번만 표시되고 서버에는 해시만 저장됩니다.", + en: "Issue a read-only Home Assistant token bound to the selected pet. The secret is shown once; only its hash is stored on the server.", + }, + apiTokenCreateReadOnly: { ko: "이 반려동물 읽기 토큰 발급", en: "Create read-only pet token" }, + apiTokenCreatedCopyNow: { ko: "토큰을 발급했습니다. 지금 복사해 안전한 곳에 저장하세요.", en: "Token created. Copy it now and store it somewhere safe." }, + apiTokenOneTimeWarning: { ko: "이 토큰은 다시 표시되지 않습니다. 복사한 뒤 HA 설정에 붙여넣으세요.", en: "This token cannot be shown again. Copy it into your Home Assistant setup now." }, + apiTokenCopied: { ko: "토큰을 클립보드에 복사했습니다.", en: "Token copied to clipboard." }, + apiTokenSelectToCopy: { ko: "아래 토큰을 선택해 복사하세요.", en: "Select the token below and copy it." }, + apiTokenLoadError: { ko: "토큰 목록을 불러오지 못했습니다.", en: "Could not load the token list." }, + apiTokenCreateError: { ko: "토큰을 발급하지 못했습니다 (HTTP {status}). 가구 소유자 권한을 확인하세요.", en: "Could not create token (HTTP {status}). Check that you are a household owner." }, + apiTokenRevokeConfirm: { ko: "'{name}' 토큰을 폐기할까요? 연결된 HA에서 더 이상 사용할 수 없습니다.", en: "Revoke '{name}'? Home Assistant will no longer be able to use it." }, + apiTokenRevoked: { ko: "토큰을 폐기했습니다.", en: "Token revoked." }, + apiTokenRevokeError: { ko: "토큰을 폐기하지 못했습니다 (HTTP {status}).", en: "Could not revoke token (HTTP {status})." }, + apiTokenAnyPet: { ko: "기본 반려동물", en: "Default pet" }, + apiTokenLastUsed: { ko: "마지막 사용", en: "Last used" }, + apiTokenNone: { ko: "발급된 토큰이 없습니다.", en: "No API tokens have been issued." }, + copyButton: { ko: "복사", en: "Copy" }, + revokeButton: { ko: "폐기", en: "Revoke" }, + neverLabel: { ko: "없음", en: "Never" }, apiExplorerRun: { ko: "실행", en: "Run" }, apiExplorerRunning: { ko: "요청 중…", en: "Running…" }, @@ -1277,4 +1298,3 @@ export function getStoredLocale(): Locale { } return "ko"; } - diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index d14a464..de2566e 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -1,4 +1,5 @@ export type UserRole = "ADMIN" | "GENERAL"; +export type HouseholdRole = "OWNER" | "MEMBER" | "VIEWER"; export type Species = "DOG" | "CAT" | "OTHER"; export type Sex = "MALE" | "FEMALE" | "UNKNOWN"; @@ -8,6 +9,7 @@ export interface User { email: string; role: UserRole; householdId: string | null; + householdRole?: HouseholdRole | null; needsPet: boolean; inSharedHousehold?: boolean; } diff --git a/docs/WORKLOG.md b/docs/WORKLOG.md index f089456..78f4add 100644 --- a/docs/WORKLOG.md +++ b/docs/WORKLOG.md @@ -1,5 +1,17 @@ # 작업 기록 +### 2026-10-05 — `hass-kibble` 읽기 전용 연동 기반 + +**한 일** + +- `GET /api/states`에 단위별 합계와 마지막 값을 담은 `todaySummary`를 추가하고 기존 응답 필드는 유지 +- 웹 설정에 OWNER용 반려동물 고정 `state:read` 토큰 발급·일회 복사·목록·폐기 UI 추가. 원문을 해시로만 저장하는 정책은 유지 +- 별도 `hass-kibble` 저장소에서 5분 상태 폴링, 요약/복약/리마인더 센서와 즉시 새로고침 버튼 구성 + +**결정**: HA→Kibble 쓰기는 이번 통합에서 지원하지 않는다. Kibble→HA 데이터는 `/api/states`의 읽기 전용 토큰만으로 제공한다. 토큰 원문은 다시 읽을 수 없으므로 웹은 발급 즉시 복사 UX를 제공하고 기존 토큰은 메타데이터만 보여준다. + +**검증**: API 상태 라우트 테스트 6개 통과, 웹 production build 및 lint 통과(기존 경고 2개), HA API-client 테스트 13개와 ruff 통과. + > **이 문서의 수명**: Phase 0~5 진행 중에만 유지한다. **정식 릴리스 전에 삭제하거나 `CHANGELOG.md`로 정리**한다. > > **목적 두 가지** diff --git a/docs/WORKPLAN.md b/docs/WORKPLAN.md index 6ab847e..ff65fb3 100644 --- a/docs/WORKPLAN.md +++ b/docs/WORKPLAN.md @@ -688,7 +688,7 @@ UI: | P2-01 | **파싱 고도화** — 실측 문장 벤치마크 세트로 정확도 개선 | Phase 1 게이트의 `needsReview` 비율을 근거로 | | P2-02 | **스캐너 이식 + 제품 바코드 바인딩** (`PresetCode.source = PRODUCT`) | S4 투약 구분, 사료 종류 추적. `@zxing` 동적 import 규칙 + 테스트 함께. 자동 생성은 하지 않는다 | | P2-03 | Web Share Target (drop 이식) | S6 | -| P2-04 | **`GET /api/states` (역방향)** | **완료** — 이름을 `/api/ha/states`에서 바꿨다: 경로에 플랫폼 이름을 박지 않는다 (K-14). `state:read` 스코프 토큰 또는 세션으로 읽는다. 반려동물별 마지막 기록·오늘 합계·밀린 복약·리마인더. **선택 기능** — HA 없이도 앱이 완전하다 (K-10) | +| P2-04 | **`GET /api/states` (역방향)** | **완료** — 이름을 `/api/ha/states`에서 바꿨다: 경로에 플랫폼 이름을 박지 않는다 (K-14). `state:read` 스코프 토큰 또는 세션으로 읽는다. 반려동물별 마지막 기록·간략 오늘 합계·단위별 상세 `todaySummary`·밀린 복약·리마인더. 별도 HA 통합은 외부 저장소 `hass-kibble`로 제공한다. **선택 기능** — HA 없이도 앱이 완전하다 (K-10) | | P2-05 | 행위 중복 경고 | `createEvent()` 내부에서만 (F2) | | P2-06 | 퀵 칩 시간대 가중 자동 정렬 | 게이트 2주 데이터를 근거로 | | P2-07 | 가족 공유 실사용 검증 | 2인 이상 동시 기록 | diff --git a/docs/api.md b/docs/api.md index 24e49cd..976793f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,6 +46,12 @@ AUTH="Authorization: Bearer $TOKEN" `scopes`를 생략하면 `["event:create"]`만 발급된다. **기존 토큰은 `state:read`가 없으므로 상태 조회를 하려면 새로 발급해야 한다.** ```bash +# 읽기 전용 상태 토큰 — 한 반려동물로 고정 +curl -sS -X POST "$BASE/api/tokens" \ + -H "Content-Type: application/json" \ + -H "$AUTH" \ + -d '{"name":"Home Assistant · 보리","scopes":["state:read"],"petId":""}' + # 토큰 발급 (plaintext는 이 응답에서만 한 번 노출) curl -sS -X POST "$BASE/api/tokens" \ -H "Content-Type: application/json" \ @@ -139,7 +145,12 @@ curl -sS -X DELETE "$BASE/api/routines/" -H "$AUTH" ## 상태 조회 (역방향) -밖에서 kibble의 현재 상태를 읽는다. 세션 또는 `state:read` 토큰. **읽기 전용이다 (K-7).** +밖에서 Kibble의 현재 상태를 읽는다. 세션 또는 `state:read` 토큰. **읽기 전용이다 (K-7).** + +응답에는 간단한 `today` 집계와 상세한 `todaySummary`가 함께 들어간다. `today`는 타입별 +횟수·합계이며, `todaySummary`는 단위별 합계(`totals`), 마지막 기록 시각, 마지막 척도값과 +측정 수량을 제공한다. 여러 단위가 섞인 기록도 `totals` 항목별로 분리된다. 양쪽 모두 KST +자정 기준이다. 기존 필드는 그대로 유지된다. ```bash # 세션으로 @@ -156,6 +167,7 @@ curl -sS "$BASE/api/states" -H "Authorization: Bearer kbl_..." | `pet` | 대상 반려동물 | | `lastEvents[]` | 이벤트 타입별 **마지막 기록** — 시각, 수량·단위, 척도값, `hoursSince`(경과 시간) | | `today[]` | 오늘(KST 기준) 타입별 **건수와 합계** — 급여량·음수량 등 | +| `todaySummary[]` | 오늘 이벤트 타입별 **단위 분리 합계와 마지막 값** — `totals`, `lastOccurredAt`, `lastScaleValue`, `lastQuantity` 등 | | `todaySince` | 오늘 합계의 시작 경계 | | `medication` | 진행 중 과정 수, 오늘 먹인/계획된 횟수, **시각이 지난 슬롯** | | `reminders[]` | 예정일과 지남 여부 |