- New PATCH /api/children/[id] — updates image_url in children table
- New POST /api/children/[id] — returns presigned R2 URL for profile
photos (stored under profiles/{childId}/ prefix, no memories row)
- FamilyProvider: expose updateChildImage() so UI updates instantly
without a full re-fetch after upload
- Home page baby card: photo avatar is now a separate tap target from
the growth link. Tap photo → file picker → upload to R2 → save URL.
Camera overlay (📷) appears on hover/tap; ⏳ shown while uploading.
Tapping name/age/arrow still navigates to growth as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { sql } from "@/db";
|
|
import { requireFamily } from "@/lib/auth";
|
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
|
|
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
|
|
|
|
async function ownershipCheck(childId: string, familyId: string) {
|
|
const rows = await sql.unsafe(
|
|
`SELECT id FROM children WHERE id = $1 AND family_id = $2`,
|
|
[childId, familyId]
|
|
);
|
|
return rows.length > 0;
|
|
}
|
|
|
|
// POST — get a presigned R2 URL for uploading a child profile photo
|
|
// (no memories row — this is purely for profile avatars)
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const auth = await requireFamily();
|
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const familyId = auth.session!.familyId!;
|
|
const { id } = await params;
|
|
|
|
if (!(await ownershipCheck(id, familyId))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { contentType, filename } = body;
|
|
|
|
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
|
|
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
|
|
}
|
|
|
|
const accountId = process.env.R2_ACCOUNT_ID;
|
|
const accessKeyId = process.env.R2_ACCESS_KEY_ID;
|
|
const secretKey = process.env.R2_SECRET_ACCESS_KEY;
|
|
const bucket = process.env.R2_BUCKET_NAME;
|
|
const publicUrl = process.env.R2_PUBLIC_URL;
|
|
|
|
if (!accountId || !accessKeyId || !secretKey || !bucket) {
|
|
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
|
|
}
|
|
|
|
const ext = filename?.split(".").pop()?.toLowerCase() || "jpg";
|
|
const r2Key = `profiles/${id}/${Date.now()}.${ext}`;
|
|
const baseUrl = publicUrl || `https://pub-${accountId}.r2.dev`;
|
|
|
|
const client = new S3Client({
|
|
region: "auto",
|
|
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
credentials: { accessKeyId, secretAccessKey: secretKey },
|
|
});
|
|
|
|
const uploadUrl = await getSignedUrl(
|
|
client,
|
|
new PutObjectCommand({ Bucket: bucket, Key: r2Key, ContentType: contentType }),
|
|
{ expiresIn: 3600 }
|
|
);
|
|
|
|
return NextResponse.json({ uploadUrl, publicUrl: `${baseUrl}/${r2Key}` });
|
|
}
|
|
|
|
// PATCH — update child profile fields (imageUrl, and extensible for name/etc later)
|
|
export async function PATCH(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const auth = await requireFamily();
|
|
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
|
|
|
const familyId = auth.session!.familyId!;
|
|
const { id } = await params;
|
|
|
|
if (!(await ownershipCheck(id, familyId))) {
|
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { imageUrl } = body;
|
|
|
|
if (imageUrl !== undefined) {
|
|
await sql.unsafe(
|
|
`UPDATE children SET image_url = $1 WHERE id = $2`,
|
|
[imageUrl, id]
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ success: true });
|
|
}
|