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