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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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" })}
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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": "*" },
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user