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
+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,
});
}