Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export async function authRoutes(app: FastifyInstance) {
email: user.email,
role: user.role,
householdId,
householdRole: request.householdRole,
needsPet,
};
});
Expand Down
10 changes: 8 additions & 2 deletions apps/api/src/routes/states.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
},
);
}
5 changes: 4 additions & 1 deletion apps/api/src/test/stateRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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만 갖고 있다 — 배포만으로 읽기 권한이 생기면 안 된다.
Expand Down
50 changes: 50 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -124,6 +125,7 @@ export default function SettingsPage() {
</div>

<PushNotificationSettings isAdmin={isAdmin} />
<ApiTokenManager isHouseholdOwner={user.householdRole === "OWNER"} />
</main>
);
}
156 changes: 156 additions & 0 deletions apps/web/components/ApiTokenManager.tsx
Original file line number Diff line number Diff line change
@@ -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<Pet[]>([]);
const [petId, setPetId] = useState("");
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [issuedToken, setIssuedToken] = useState<IssuedToken | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(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<ApiToken[]>,
petResponse.json() as Promise<Pet[]>,
]);
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 (
<section className="card api-token-manager">
<h2>{t("apiTokenManagerTitle")}</h2>
<p className="meta">{t("apiTokenManagerHint")}</p>
{pets.length > 0 ? (
<label className="field-label" htmlFor="api-token-pet">
{t("apiExplorerPetLabel")}
<select id="api-token-pet" value={petId} onChange={(event) => setPetId(event.target.value)}>
{pets.map((pet) => <option key={pet.id} value={pet.id}>{pet.name}</option>)}
</select>
</label>
) : (
<p className="meta">{t("apiExplorerNoPets")}</p>
)}
<button type="button" className="secondary" onClick={() => void createToken()} disabled={!petId || busy}>
{busy ? t("processingLabel") : t("apiTokenCreateReadOnly")}
</button>
{message && <p className="meta" role="status">{message}</p>}
{issuedToken && (
<div className="api-token-issued">
<p>{t("apiTokenOneTimeWarning")}</p>
<code>{issuedToken.token}</code>
<button type="button" className="secondary" onClick={() => void copyToken()}>
{t("copyButton")}
</button>
</div>
)}
<div className="api-token-list">
{tokens.map((token) => (
<div className="api-token-row" key={token.id}>
<div>
<strong>{token.name}</strong>
<p className="meta">
{token.pet?.name ?? t("apiTokenAnyPet")} · {token.scopes.join(", ")} · {t("apiTokenLastUsed")}: {token.lastUsedAt ?? t("neverLabel")}
</p>
</div>
<button type="button" className="danger" onClick={() => void revokeToken(token)} disabled={busy}>
{t("revokeButton")}
</button>
</div>
))}
{tokens.length === 0 && <p className="meta">{t("apiTokenNone")}</p>}
</div>
</section>
);
}
22 changes: 21 additions & 1 deletion apps/web/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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…" },

Expand Down Expand Up @@ -1277,4 +1298,3 @@ export function getStoredLocale(): Locale {
}
return "ko";
}

2 changes: 2 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -8,6 +9,7 @@ export interface User {
email: string;
role: UserRole;
householdId: string | null;
householdRole?: HouseholdRole | null;
needsPet: boolean;
inSharedHousehold?: boolean;
}
Expand Down
12 changes: 12 additions & 0 deletions docs/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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`로 정리**한다.
>
> **목적 두 가지**
Expand Down
2 changes: 1 addition & 1 deletion docs/WORKPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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인 이상 동시 기록 |
Expand Down
14 changes: 13 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<pet-id>"}'

# 토큰 발급 (plaintext는 이 응답에서만 한 번 노출)
curl -sS -X POST "$BASE/api/tokens" \
-H "Content-Type: application/json" \
Expand Down Expand Up @@ -139,7 +145,12 @@ curl -sS -X DELETE "$BASE/api/routines/<id>" -H "$AUTH"

## 상태 조회 (역방향)

밖에서 kibble의 현재 상태를 읽는다. 세션 또는 `state:read` 토큰. **읽기 전용이다 (K-7).**
밖에서 Kibble의 현재 상태를 읽는다. 세션 또는 `state:read` 토큰. **읽기 전용이다 (K-7).**

응답에는 간단한 `today` 집계와 상세한 `todaySummary`가 함께 들어간다. `today`는 타입별
횟수·합계이며, `todaySummary`는 단위별 합계(`totals`), 마지막 기록 시각, 마지막 척도값과
측정 수량을 제공한다. 여러 단위가 섞인 기록도 `totals` 항목별로 분리된다. 양쪽 모두 KST
자정 기준이다. 기존 필드는 그대로 유지된다.

```bash
# 세션으로
Expand All @@ -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[]` | 예정일과 지남 여부 |
Expand Down
Loading