feat: REST API v1 + API key management + Swagger docs

**API system**
- ApiKey model: SHA-256-hashed tokens (gw_<hex>), per-family, scoped
- Migration: 20260615210000_api_keys
- src/lib/api-auth.ts: verifyApiKey(), hasScope(), generateApiKey(), prepareKey()

**V1 endpoints** (all require Bearer gw_ token):
- GET  /api/v1/babies              — list family babies (any read scope)
- GET  /api/v1/events              — query events (events:read), babyId/type/from/to/limit/offset
- POST /api/v1/events              — log event (events:write), full metadata support
- GET  /api/v1/growth              — growth logs (growth:read)
- POST /api/v1/growth              — add measurement (growth:write), weight in grams
- GET  /api/v1/summary             — today's counts + sleep + last feed (summary:read)
- GET  /api/v1/milk                — stock lots + totalMl (milk:read)

**API key management**
- GET  /api/api-keys               — list keys (session auth)
- POST /api/api-keys               — create key, returns raw token once (session auth)
- DELETE /api/api-keys/[id]        — revoke key (session auth)

**Documentation**
- GET /api/v1/openapi.json         — OpenAPI 3.0 spec (CORS open)
- GET /api-docs                    — Swagger UI (CDN, dark themed)

**Settings UI** — "Clés API" section: create key with scope checkboxes, copy token banner (shown once), revoke, link to /api-docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:33:25 +02:00
parent 009090b381
commit f59a6c5d8a
13 changed files with 886 additions and 1 deletions
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"keyHash" TEXT NOT NULL,
"keyPrefix" TEXT NOT NULL,
"scopes" TEXT[],
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+13
View File
@@ -91,12 +91,25 @@ model Family {
users User[]
babies Baby[]
eventTemplates EventTemplate[]
apiKeys ApiKey[]
pinnedNote String?
pinnedNoteUpdatedAt DateTime?
pinnedNoteAuthor String?
createdAt DateTime @default(now())
}
model ApiKey {
id String @id @default(cuid())
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
name String
keyHash String @unique
keyPrefix String
scopes String[]
lastUsedAt DateTime?
createdAt DateTime @default(now())
}
model EventTemplate {
id String @id @default(cuid())
familyId String
+155 -1
View File
@@ -5,7 +5,7 @@ import { signOut } from "next-auth/react";
import React, { useState, useEffect } from "react";
import { useBaby } from "@/contexts/baby-context";
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send } from "lucide-react";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -28,6 +28,157 @@ interface Family {
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 dark:text-slate-100";
const ALL_SCOPES = [
{ value: "events:read", label: "Lire les événements" },
{ value: "events:write", label: "Créer des événements" },
{ value: "growth:read", label: "Lire la croissance" },
{ value: "growth:write", label: "Ajouter des mesures" },
{ value: "milk:read", label: "Lire le stock de lait" },
{ value: "summary:read", label: "Résumé journalier" },
];
function ApiKeysSection() {
const qc = useQueryClient();
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newScopes, setNewScopes] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
const { data } = useQuery<{ keys: { id: string; name: string; keyPrefix: string; scopes: string[]; lastUsedAt: string | null; createdAt: string }[] }>({
queryKey: ["api-keys"],
queryFn: () => fetch("/api/api-keys").then((r) => r.json()),
});
const keys = data?.keys ?? [];
function toggleScope(s: string) {
setNewScopes((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
}
async function createKey(e: React.FormEvent) {
e.preventDefault();
if (!newName.trim() || newScopes.length === 0) return;
setSaving(true);
const res = await fetch("/api/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newName.trim(), scopes: newScopes }),
});
const json = await res.json();
qc.invalidateQueries({ queryKey: ["api-keys"] });
setNewToken(json.token);
setNewName("");
setNewScopes([]);
setCreating(false);
setSaving(false);
}
async function deleteKey(id: string) {
if (!confirm("Révoquer cette clé ?")) return;
await fetch(`/api/api-keys/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["api-keys"] });
}
function copyToken() {
if (!newToken) return;
navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
}
return (
<Section title="Clés API">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500 dark:text-slate-400">
Permettent à des services externes de lire et écrire des données.
</p>
<a href="/api-docs" target="_blank"
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:underline">
<ExternalLink className="w-3 h-3" />Docs
</a>
</div>
{/* Newly created token — shown once */}
{newToken && (
<div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p className="text-xs font-semibold text-green-700 dark:text-green-400 mb-2">Clé créée copiez-la maintenant, elle ne sera plus affichée.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-[10px] bg-white dark:bg-slate-900 border border-green-200 dark:border-green-800 rounded px-2 py-1.5 break-all text-green-800 dark:text-green-300 select-all">
{newToken}
</code>
<button onClick={copyToken}
className="flex-shrink-0 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-xs font-medium transition flex items-center gap-1">
{tokenCopied ? <><Check className="w-3 h-3" />Copié</> : <><Copy className="w-3 h-3" />Copier</>}
</button>
</div>
<button onClick={() => setNewToken(null)} className="text-[10px] text-green-600 dark:text-green-500 mt-2 hover:underline">Fermer</button>
</div>
)}
{/* Existing keys */}
{keys.length > 0 && (
<div className="divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{keys.map((k) => (
<div key={k.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
<Key className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{k.name}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">
{k.keyPrefix} · {k.scopes.join(", ")}
{k.lastUsedAt ? ` · utilisée ${new Date(k.lastUsedAt).toLocaleDateString("fr")}` : " · jamais utilisée"}
</p>
</div>
<button onClick={() => deleteKey(k.id)}
className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Create form */}
{creating ? (
<form onSubmit={createKey} className="space-y-3 border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<input value={newName} onChange={(e) => setNewName(e.target.value)}
placeholder="Nom (ex: Home Assistant)" className={inputCls} required />
<div>
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Permissions</p>
<div className="grid grid-cols-2 gap-1.5">
{ALL_SCOPES.map((s) => (
<label key={s.value} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={newScopes.includes(s.value)} onChange={() => toggleScope(s.value)}
className="rounded border-slate-300 text-indigo-600" />
<span className="text-xs text-slate-600 dark:text-slate-300">{s.label}</span>
</label>
))}
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving || !newName.trim() || newScopes.length === 0}
className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-xs font-medium disabled:opacity-50">
{saving ? "..." : "Créer"}
</button>
<button type="button" onClick={() => setCreating(false)}
className="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500">
Annuler
</button>
</div>
</form>
) : (
<button onClick={() => setCreating(true)}
className="flex items-center gap-2 text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
<Plus className="w-4 h-4" />
Nouvelle clé API
</button>
)}
</div>
</Section>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { selectedBaby, setSelectedBabyId } = useBaby();
@@ -536,6 +687,9 @@ export default function SettingsPage() {
</Section>
)}
{/* API Keys */}
<ApiKeysSection />
{/* Sign out */}
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
+34
View File
@@ -0,0 +1,34 @@
export async function GET(req: Request) {
const base = new URL(req.url).origin;
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Grow API — Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; background: #0f172a; }
.swagger-ui .topbar { background: #1e293b; border-bottom: 1px solid #334155; }
.swagger-ui .topbar .download-url-wrapper { display: none; }
.swagger-ui .info .title { color: #e2e8f0; }
.swagger-ui .scheme-container { background: #1e293b; border-bottom: 1px solid #334155; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "${base}/api/v1/openapi.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
deepLinking: true,
persistAuthorization: true,
});
</script>
</body>
</html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const familyId = (session.user as { familyId?: string }).familyId;
const { id } = await params;
const key = await prisma.apiKey.findUnique({ where: { id } });
if (!key || key.familyId !== familyId) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
await prisma.apiKey.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateApiKey, prepareKey, API_SCOPES } from "@/lib/api-auth";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ keys: [] });
const keys = await prisma.apiKey.findMany({
where: { familyId },
orderBy: { createdAt: "desc" },
select: { id: true, name: true, keyPrefix: true, scopes: true, lastUsedAt: true, createdAt: true },
});
return NextResponse.json({ keys });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 400 });
const body = await req.json().catch(() => ({}));
const { name, scopes } = body;
if (!name?.trim()) return NextResponse.json({ error: "name is required" }, { status: 400 });
const validScopes = (scopes as string[] | undefined)?.filter((s) => (API_SCOPES as readonly string[]).includes(s));
if (!validScopes?.length) return NextResponse.json({ error: "At least one valid scope is required" }, { status: 400 });
const token = generateApiKey();
const data = prepareKey(token, name.trim(), familyId, validScopes);
const key = await prisma.apiKey.create({
data,
select: { id: true, name: true, keyPrefix: true, scopes: true, createdAt: true },
});
// Return the raw token ONCE — it cannot be retrieved again
return NextResponse.json({ key, token }, { status: 201 });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyApiKey, hasScope } from "@/lib/api-auth";
export async function GET(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "events:read") && !hasScope(ctx, "summary:read") && !hasScope(ctx, "growth:read") && !hasScope(ctx, "milk:read"))
return NextResponse.json({ error: "Insufficient scope" }, { status: 403 });
const babies = await prisma.baby.findMany({
where: { familyId: ctx.familyId },
select: { id: true, name: true, birthDate: true, gender: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
return NextResponse.json({ babies });
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyApiKey, hasScope } from "@/lib/api-auth";
const VALID_TYPES = [
"BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL",
"NAP", "NIGHT_SLEEP", "MEDICATION", "TEMPERATURE", "BATH",
"WALK", "TUMMY_TIME", "SYMPTOM",
];
export async function GET(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "events:read")) return NextResponse.json({ error: "Insufficient scope — requires events:read" }, { status: 403 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const type = searchParams.get("type");
const from = searchParams.get("from");
const to = searchParams.get("to");
const limit = Math.min(parseInt(searchParams.get("limit") ?? "50"), 500);
const offset = parseInt(searchParams.get("offset") ?? "0");
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
// Verify baby belongs to family
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
const where: Record<string, unknown> = { babyId };
if (type) {
if (!VALID_TYPES.includes(type)) return NextResponse.json({ error: `Invalid type. Valid: ${VALID_TYPES.join(", ")}` }, { status: 400 });
where.type = type;
}
if (from || to) {
where.startedAt = {};
if (from) (where.startedAt as Record<string, unknown>).gte = new Date(from);
if (to) (where.startedAt as Record<string, unknown>).lte = new Date(to);
}
const [events, total] = await Promise.all([
prisma.event.findMany({
where,
orderBy: { startedAt: "desc" },
take: limit,
skip: offset,
select: {
id: true, type: true, startedAt: true, endedAt: true,
notes: true, metadata: true, createdAt: true,
user: { select: { id: true, name: true } },
},
}),
prisma.event.count({ where }),
]);
return NextResponse.json({ events, total, limit, offset });
}
export async function POST(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "events:write")) return NextResponse.json({ error: "Insufficient scope — requires events:write" }, { status: 403 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
const { babyId, type, startedAt, endedAt, notes, metadata } = body;
if (!babyId || !type || !startedAt) {
return NextResponse.json({ error: "babyId, type, and startedAt are required" }, { status: 400 });
}
if (!VALID_TYPES.includes(type)) {
return NextResponse.json({ error: `Invalid type. Valid: ${VALID_TYPES.join(", ")}` }, { status: 400 });
}
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
// API-created events are attributed to the first parent in the family
const user = await prisma.user.findFirst({
where: { familyId: ctx.familyId, role: "PARENT" },
});
if (!user) return NextResponse.json({ error: "No parent user in family" }, { status: 500 });
const event = await prisma.event.create({
data: {
babyId,
userId: user.id,
type,
startedAt: new Date(startedAt),
endedAt: endedAt ? new Date(endedAt) : null,
notes: notes ?? null,
metadata: metadata ?? undefined,
},
select: { id: true, type: true, startedAt: true, endedAt: true, notes: true, metadata: true, createdAt: true },
});
return NextResponse.json({ event }, { status: 201 });
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyApiKey, hasScope } from "@/lib/api-auth";
export async function GET(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "growth:read")) return NextResponse.json({ error: "Insufficient scope — requires growth:read" }, { status: 403 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
const logs = await prisma.growthLog.findMany({
where: { babyId },
orderBy: { date: "asc" },
select: { id: true, date: true, weight: true, height: true, headCirc: true, notes: true, createdAt: true },
});
return NextResponse.json({ logs });
}
export async function POST(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "growth:write")) return NextResponse.json({ error: "Insufficient scope — requires growth:write" }, { status: 403 });
const body = await req.json().catch(() => null);
if (!body) return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
const { babyId, date, weight, height, headCirc, notes } = body;
if (!babyId || !date) return NextResponse.json({ error: "babyId and date are required" }, { status: 400 });
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
const log = await prisma.growthLog.create({
data: {
babyId,
date: new Date(date),
weight: weight != null ? Number(weight) : null,
height: height != null ? Number(height) : null,
headCirc: headCirc != null ? Number(headCirc) : null,
notes: notes ?? null,
},
select: { id: true, date: true, weight: true, height: true, headCirc: true, notes: true, createdAt: true },
});
return NextResponse.json({ log }, { status: 201 });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyApiKey, hasScope } from "@/lib/api-auth";
export async function GET(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "milk:read")) return NextResponse.json({ error: "Insufficient scope — requires milk:read" }, { status: 403 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
const includeUsed = searchParams.get("includeUsed") === "true";
const lots = await prisma.milkStock.findMany({
where: { babyId, ...(includeUsed ? {} : { used: false }) },
orderBy: { storedAt: "desc" },
select: {
id: true, volume: true, location: true, storedAt: true,
expiresAt: true, used: true, usedAt: true, notes: true,
},
});
const totalMl = lots.filter((l) => !l.used).reduce((acc, l) => acc + l.volume, 0);
return NextResponse.json({ lots, totalMl });
}
+291
View File
@@ -0,0 +1,291 @@
import { NextResponse } from "next/server";
const spec = {
openapi: "3.0.3",
info: {
title: "Grow API",
version: "1.0.0",
description:
"REST API for the Grow baby-tracking app. All endpoints require a Bearer API key created in Settings → Clés API.",
contact: { name: "Grow" },
},
servers: [{ url: "/api/v1", description: "Current server" }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "gw_<hex>",
description: "API key generated in Settings → Clés API. Format: `gw_<64 hex chars>`",
},
},
schemas: {
Baby: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
birthDate: { type: "string", format: "date-time" },
gender: { type: "string", nullable: true },
createdAt: { type: "string", format: "date-time" },
},
},
Event: {
type: "object",
properties: {
id: { type: "string" },
type: { $ref: "#/components/schemas/EventType" },
startedAt: { type: "string", format: "date-time" },
endedAt: { type: "string", format: "date-time", nullable: true },
notes: { type: "string", nullable: true },
metadata: { type: "object", nullable: true },
createdAt: { type: "string", format: "date-time" },
user: { type: "object", properties: { id: { type: "string" }, name: { type: "string" } } },
},
},
EventType: {
type: "string",
enum: [
"BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL",
"NAP", "NIGHT_SLEEP", "MEDICATION", "TEMPERATURE",
"BATH", "WALK", "TUMMY_TIME", "SYMPTOM",
],
},
GrowthLog: {
type: "object",
properties: {
id: { type: "string" },
date: { type: "string", format: "date-time" },
weight: { type: "number", nullable: true, description: "Weight in grams" },
height: { type: "number", nullable: true, description: "Height in cm" },
headCirc: { type: "number", nullable: true, description: "Head circumference in cm" },
notes: { type: "string", nullable: true },
createdAt: { type: "string", format: "date-time" },
},
},
MilkLot: {
type: "object",
properties: {
id: { type: "string" },
volume: { type: "number", description: "Volume in mL" },
location: { type: "string", enum: ["room", "fridge", "freezer"] },
storedAt: { type: "string", format: "date-time" },
expiresAt: { type: "string", format: "date-time" },
used: { type: "boolean" },
usedAt: { type: "string", format: "date-time", nullable: true },
notes: { type: "string", nullable: true },
},
},
Error: {
type: "object",
properties: { error: { type: "string" } },
},
},
},
paths: {
"/babies": {
get: {
tags: ["Babies"],
summary: "List babies",
description: "Returns all babies in the authenticated family.",
responses: {
200: {
description: "List of babies",
content: {
"application/json": {
schema: {
type: "object",
properties: { babies: { type: "array", items: { $ref: "#/components/schemas/Baby" } } },
},
},
},
},
401: { description: "Invalid or missing API key", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/events": {
get: {
tags: ["Events"],
summary: "Query events",
parameters: [
{ name: "babyId", in: "query", required: true, schema: { type: "string" } },
{ name: "type", in: "query", schema: { $ref: "#/components/schemas/EventType" } },
{ name: "from", in: "query", schema: { type: "string", format: "date-time" }, description: "ISO 8601 start date" },
{ name: "to", in: "query", schema: { type: "string", format: "date-time" }, description: "ISO 8601 end date" },
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 500 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
],
security: [{ bearerAuth: ["events:read"] }],
responses: {
200: {
description: "Paginated events",
content: {
"application/json": {
schema: {
type: "object",
properties: {
events: { type: "array", items: { $ref: "#/components/schemas/Event" } },
total: { type: "integer" },
limit: { type: "integer" },
offset: { type: "integer" },
},
},
},
},
},
401: { description: "Invalid or missing API key" },
403: { description: "Insufficient scope" },
404: { description: "Baby not found" },
},
},
post: {
tags: ["Events"],
summary: "Log an event",
security: [{ bearerAuth: ["events:write"] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["babyId", "type", "startedAt"],
properties: {
babyId: { type: "string" },
type: { $ref: "#/components/schemas/EventType" },
startedAt: { type: "string", format: "date-time" },
endedAt: { type: "string", format: "date-time" },
notes: { type: "string" },
metadata: {
type: "object",
description: "Type-specific fields. Examples: `{volume: 120}` for BOTTLE, `{breastSide: \"left\"}` for BREASTFEED, `{value: 38.5, unit: \"C\"}` for TEMPERATURE, `{tags: [\"Fièvre\", \"Toux\"]}` for SYMPTOM",
},
},
},
examples: {
bottle: { summary: "Bottle feed", value: { babyId: "clxxx", type: "BOTTLE", startedAt: "2025-06-01T10:30:00Z", metadata: { volume: 120, bottleType: "maternal" } } },
breastfeed: { summary: "Breastfeed session", value: { babyId: "clxxx", type: "BREASTFEED", startedAt: "2025-06-01T08:00:00Z", endedAt: "2025-06-01T08:20:00Z", metadata: { breastSide: "left" } } },
diaper: { summary: "Wet diaper", value: { babyId: "clxxx", type: "DIAPER_WET", startedAt: "2025-06-01T11:00:00Z" } },
symptom: { summary: "Symptom log", value: { babyId: "clxxx", type: "SYMPTOM", startedAt: "2025-06-01T14:00:00Z", metadata: { tags: ["Fièvre", "Irritabilité"] }, notes: "Temp 38.2°C" } },
},
},
},
},
responses: {
201: { description: "Event created", content: { "application/json": { schema: { type: "object", properties: { event: { $ref: "#/components/schemas/Event" } } } } } },
400: { description: "Validation error" },
401: { description: "Invalid or missing API key" },
403: { description: "Insufficient scope" },
},
},
},
"/growth": {
get: {
tags: ["Growth"],
summary: "Get growth measurements",
security: [{ bearerAuth: ["growth:read"] }],
parameters: [{ name: "babyId", in: "query", required: true, schema: { type: "string" } }],
responses: {
200: {
description: "Growth logs",
content: { "application/json": { schema: { type: "object", properties: { logs: { type: "array", items: { $ref: "#/components/schemas/GrowthLog" } } } } } },
},
},
},
post: {
tags: ["Growth"],
summary: "Add a growth measurement",
security: [{ bearerAuth: ["growth:write"] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["babyId", "date"],
properties: {
babyId: { type: "string" },
date: { type: "string", format: "date-time" },
weight: { type: "number", description: "Weight in grams (e.g. 4250)" },
height: { type: "number", description: "Height in cm" },
headCirc: { type: "number", description: "Head circumference in cm" },
notes: { type: "string" },
},
},
examples: {
weighin: { summary: "Weight check", value: { babyId: "clxxx", date: "2025-06-01T10:00:00Z", weight: 4250, height: 55.5 } },
},
},
},
},
responses: {
201: { description: "Measurement created" },
400: { description: "Validation error" },
},
},
},
"/summary": {
get: {
tags: ["Summary"],
summary: "Today's activity summary",
description: "Returns counts of today's events grouped by type, plus total sleep and last feed time.",
security: [{ bearerAuth: ["summary:read"] }],
parameters: [{ name: "babyId", in: "query", required: true, schema: { type: "string" } }],
responses: {
200: {
description: "Daily summary",
content: {
"application/json": {
schema: {
type: "object",
properties: {
date: { type: "string", format: "date" },
counts: { type: "object", additionalProperties: { type: "integer" } },
feeds: { type: "integer" },
diapers: { type: "integer" },
sleepMinutes: { type: "integer" },
lastFeedAt: { type: "string", format: "date-time", nullable: true },
},
},
},
},
},
},
},
},
"/milk": {
get: {
tags: ["Milk Stock"],
summary: "Get milk stock lots",
security: [{ bearerAuth: ["milk:read"] }],
parameters: [
{ name: "babyId", in: "query", required: true, schema: { type: "string" } },
{ name: "includeUsed", in: "query", schema: { type: "boolean", default: false }, description: "Include used/consumed lots" },
],
responses: {
200: {
description: "Milk lots",
content: {
"application/json": {
schema: {
type: "object",
properties: {
lots: { type: "array", items: { $ref: "#/components/schemas/MilkLot" } },
totalMl: { type: "number", description: "Total available volume in mL" },
},
},
},
},
},
},
},
},
},
};
export async function GET() {
return NextResponse.json(spec, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verifyApiKey, hasScope } from "@/lib/api-auth";
export async function GET(req: Request) {
const ctx = await verifyApiKey(req);
if (!ctx) return NextResponse.json({ error: "Invalid or missing API key" }, { status: 401 });
if (!hasScope(ctx, "summary:read")) return NextResponse.json({ error: "Insufficient scope — requires summary:read" }, { status: 403 });
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
const dayStart = new Date();
dayStart.setHours(0, 0, 0, 0);
const events = await prisma.event.findMany({
where: { babyId, startedAt: { gte: dayStart } },
orderBy: { startedAt: "desc" },
select: { type: true, startedAt: true, endedAt: true, metadata: true },
});
const counts: Record<string, number> = {};
for (const ev of events) counts[ev.type] = (counts[ev.type] ?? 0) + 1;
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
const lastFeed = feeds[0] ?? null;
const sleepMs = events
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
.reduce((acc, e) => acc + new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime(), 0);
return NextResponse.json({
date: dayStart.toISOString().slice(0, 10),
counts,
feeds: counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0),
diapers: (counts.DIAPER_WET ?? 0) + (counts.DIAPER_STOOL ?? 0),
sleepMinutes: Math.round(sleepMs / 60000),
lastFeedAt: lastFeed ? lastFeed.startedAt : null,
});
}
+68
View File
@@ -0,0 +1,68 @@
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
export const API_SCOPES = [
"events:read",
"events:write",
"growth:read",
"growth:write",
"milk:read",
"summary:read",
] as const;
export type ApiScope = typeof API_SCOPES[number];
export interface ApiContext {
familyId: string;
scopes: string[];
keyId: string;
}
function hashKey(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function generateApiKey(): string {
const arr = new Uint8Array(32);
// Use crypto.getRandomValues if available (edge), else randomBytes
if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.getRandomValues) {
globalThis.crypto.getRandomValues(arr);
return "gw_" + Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("");
}
// Node.js fallback
const { randomBytes } = require("crypto") as typeof import("crypto");
return "gw_" + randomBytes(32).toString("hex");
}
export function keyPrefix(token: string): string {
return token.slice(0, 10);
}
export async function verifyApiKey(req: Request): Promise<ApiContext | null> {
const auth = req.headers.get("authorization");
if (!auth?.startsWith("Bearer gw_")) return null;
const token = auth.slice(7);
const hash = hashKey(token);
const key = await prisma.apiKey.findUnique({ where: { keyHash: hash } });
if (!key) return null;
// Fire-and-forget lastUsedAt update
prisma.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return { familyId: key.familyId, scopes: key.scopes, keyId: key.id };
}
export function hasScope(ctx: ApiContext, scope: ApiScope): boolean {
return ctx.scopes.includes(scope);
}
export function prepareKey(token: string, name: string, familyId: string, scopes: string[]) {
return {
familyId,
name,
keyHash: hashKey(token),
keyPrefix: keyPrefix(token),
scopes,
};
}