Fix broken memories count + silent upload failures

Memories "count 2 but no images" root cause:
- DB rows exist with processing_status='failed'/'uploading' from aborted uploads
  whose R2 objects never actually landed. The img onError fires and hides the
  tile, but the count still includes these orphaned rows.
- Fix: GET /api/memories now excludes failed rows and uploading rows older than
  30 min from both the SELECT and the count. Also fires a background DELETE to
  clean up orphaned rows so they stop accumulating.

Profile / memories upload silent failures:
- Some Android cameras return file.type="" which caused the avatar API to reject
  the upload with a 400 error. Error was caught but shown in a small text node
  buried below the form — invisible when looking at the avatar area.
- Fix: added resolveContentType() helper (used in profile, memories, home) that
  falls back to extension-based detection when file.type is empty/octet-stream.
- Fix: profile page now uses a separate uploadMsg state rendered immediately
  below the avatar so errors/success are always visible on mobile without scroll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 10:48:28 +05:30
parent 9c2e7328ab
commit ccae6d85d2
5 changed files with 116 additions and 25 deletions

File diff suppressed because one or more lines are too long

View file

@ -11,6 +11,18 @@ import { calculateAge, formatTimeAgo } from "@/lib/formatting";
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist"; import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
import type { Log, AIChat, ChatSession } from "@/types"; import type { Log, AIChat, ChatSession } from "@/types";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
async function getSessions(cid: string): Promise<ChatSession[]> { async function getSessions(cid: string): Promise<ChatSession[]> {
try { try {
const res = await fetch(`/api/chat?childId=${cid}`); const res = await fetch(`/api/chat?childId=${cid}`);
@ -189,22 +201,23 @@ export default function HomePage() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file || !childId) return; if (!file || !childId) return;
setUploadingPhoto(true); setUploadingPhoto(true);
const contentType = resolveContentType(file);
try { try {
// 1. Get R2 key + public URL from server // 1. Get R2 key + public URL from server
const initRes = await fetch(`/api/children/${childId}`, { const initRes = await fetch(`/api/children/${childId}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }), body: JSON.stringify({ contentType, filename: file.name }),
}); });
if (!initRes.ok) throw new Error("Failed to get upload URL"); if (!initRes.ok) throw new Error("Failed to get upload URL");
const { key, publicUrl } = await initRes.json(); const { key, publicUrl } = await initRes.json();
// 2. Upload via server proxy — avoids CORS on direct R2 PUT // 2. Upload via server proxy — avoids CORS on direct R2 PUT
const putParams = new URLSearchParams({ key, contentType: file.type }); const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, { const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", method: "PUT",
body: file, body: file,
headers: { "Content-Type": file.type }, headers: { "Content-Type": contentType },
}); });
if (!putRes.ok) throw new Error("Upload failed"); if (!putRes.ok) throw new Error("Upload failed");

View file

@ -7,6 +7,19 @@ import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter"; import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes"; import { formatBytes } from "@/lib/format-bytes";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
gif: "image/gif",
};
return map[ext] || "image/jpeg";
}
const PRESET_FOLDERS = [ const PRESET_FOLDERS = [
{ id: "", label: "All", emoji: "🌟" }, { id: "", label: "All", emoji: "🌟" },
{ id: "first-steps", label: "First Steps", emoji: "👣" }, { id: "first-steps", label: "First Steps", emoji: "👣" },
@ -144,12 +157,13 @@ export default function MemoriesPage() {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file || !childId) return; if (!file || !childId) return;
setUploading(true); setUploading(true);
const contentType = resolveContentType(file);
try { try {
// Step 1: Get presigned URL (quota gate runs here server-side) // Step 1: Get presigned URL (quota gate runs here server-side)
const initRes = await fetch("/api/upload", { const initRes = await fetch("/api/upload", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type, childId, sizeBytes: file.size }), body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
}); });
const initData = await initRes.json(); const initData = await initRes.json();
@ -166,9 +180,9 @@ export default function MemoriesPage() {
const { key, memoryId, publicUrl } = initData; const { key, memoryId, publicUrl } = initData;
// Step 2: Upload file to R2 via proxy // Step 2: Upload file to R2 via proxy
const putParams = new URLSearchParams({ key, contentType: file.type }); const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, { const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", body: file, headers: { "Content-Type": file.type }, method: "PUT", body: file, headers: { "Content-Type": contentType },
}); });
if (!putRes.ok) { if (!putRes.ok) {
const putErr = await putRes.json().catch(() => ({})) as { error?: string }; const putErr = await putRes.json().catch(() => ({})) as { error?: string };

View file

@ -3,11 +3,22 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
};
return map[ext] || "image/jpeg";
}
export default function ProfilePage() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [userId, setUserId] = useState<string>("");
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [avatarUrl, setAvatarUrl] = useState<string | null>(null); const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
@ -15,6 +26,8 @@ export default function ProfilePage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [saveMsg, setSaveMsg] = useState(""); const [saveMsg, setSaveMsg] = useState("");
// Separate upload feedback shown right below the avatar — always visible on mobile
const [uploadMsg, setUploadMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
useEffect(() => { useEffect(() => {
@ -22,7 +35,6 @@ export default function ProfilePage() {
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.user) { if (data.user) {
setUserId(data.user.id || "");
setName(data.user.name || ""); setName(data.user.name || "");
setEmail(data.user.email || ""); setEmail(data.user.email || "");
setAvatarUrl(data.user.avatarUrl || null); setAvatarUrl(data.user.avatarUrl || null);
@ -37,26 +49,30 @@ export default function ProfilePage() {
if (!file) return; if (!file) return;
setUploading(true); setUploading(true);
setUploadMsg(null);
setSaveMsg(""); setSaveMsg("");
const contentType = resolveContentType(file);
try { try {
// Step 1: get R2 key + presigned upload URL // Step 1: get R2 key + presigned upload URL
const initRes = await fetch("/api/auth/avatar", { const initRes = await fetch("/api/auth/avatar", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }), body: JSON.stringify({ contentType, filename: file.name }),
}); });
const initData = await initRes.json(); const initData = await initRes.json();
if (!initRes.ok) throw new Error(initData.error || "Upload init failed"); if (!initRes.ok) throw new Error(initData.error || "Could not start upload");
const { key, publicUrl: newPublicUrl } = initData; const { key, publicUrl: newPublicUrl } = initData;
// Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT) // Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT)
const putParams = new URLSearchParams({ key, contentType: file.type }); const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, { const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", method: "PUT",
body: file, body: file,
headers: { "Content-Type": file.type }, headers: { "Content-Type": contentType },
}); });
if (!putRes.ok) throw new Error("Upload to storage failed"); if (!putRes.ok) throw new Error("Upload to storage failed — please try again");
// Step 3: save the new avatarUrl in DB (server cleans up old R2 object) // Step 3: save the new avatarUrl in DB (server cleans up old R2 object)
const patchRes = await fetch("/api/auth/avatar", { const patchRes = await fetch("/api/auth/avatar", {
@ -69,24 +85,26 @@ export default function ProfilePage() {
setAvatarUrl(newPublicUrl); setAvatarUrl(newPublicUrl);
setAvatarError(false); setAvatarError(false);
setSaveMsg("Photo updated!"); setUploadMsg({ text: "✓ Photo updated!", ok: true });
} catch (err) { } catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Upload failed"); setUploadMsg({ text: err instanceof Error ? err.message : "Upload failed", ok: false });
} }
setUploading(false); setUploading(false);
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
}; };
const handleRemovePhoto = async () => { const handleRemovePhoto = async () => {
setUploadMsg(null);
setSaveMsg(""); setSaveMsg("");
try { try {
const res = await fetch("/api/auth/avatar", { method: "DELETE" }); const res = await fetch("/api/auth/avatar", { method: "DELETE" });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to remove photo"); if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null); setAvatarUrl(null);
setSaveMsg("Photo removed."); setAvatarError(false);
setUploadMsg({ text: "Photo removed.", ok: true });
} catch (err) { } catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo"); setUploadMsg({ text: err instanceof Error ? err.message : "Failed to remove photo", ok: false });
} }
}; };
@ -162,12 +180,21 @@ export default function ProfilePage() {
Remove photo Remove photo
</button> </button>
)} )}
{!avatarUrl && <p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>} {!avatarUrl && !uploading && (
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP</p>
)}
{/* Upload result — shown RIGHT HERE below the avatar, always visible on mobile */}
{uploadMsg && (
<p className={`mt-2 text-sm font-medium text-center px-4 ${uploadMsg.ok ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{uploadMsg.text}
</p>
)}
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept="image/jpeg,image/jpg,image/png,image/webp" accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
onChange={handlePhotoChange} onChange={handlePhotoChange}
className="hidden" className="hidden"
/> />
@ -201,7 +228,7 @@ export default function ProfilePage() {
</div> </div>
{saveMsg && ( {saveMsg && (
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" || saveMsg === "Photo updated!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}> <p className={`text-xs text-center font-medium ${saveMsg === "Saved!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{saveMsg} {saveMsg}
</p> </p>
)} )}

View file

@ -39,15 +39,52 @@ export async function GET(req: NextRequest) {
const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100); const limit = Math.min(parseInt(searchParams.get("limit") || "30"), 100);
const cursor = searchParams.get("cursor"); // ISO timestamp for pagination const cursor = searchParams.get("cursor"); // ISO timestamp for pagination
// Orphaned row definition:
// - processing_status = 'failed' → confirm step ran, HeadObject found nothing in R2
// - processing_status = 'uploading' older than 30 min → browser closed/connection dropped
// before the upload and confirm steps completed; file never reached R2.
// These rows have no R2 object behind them. Delete them silently so they stop
// inflating the count and making the grid look broken.
sql`
DELETE FROM memories
WHERE family_id = ${familyId}
AND (
processing_status = 'failed'
OR (processing_status = 'uploading' AND created_at < NOW() - INTERVAL '30 minutes')
)
`.catch(() => {}); // fire-and-forget, don't block the response
let rows; let rows;
if (childId) { if (childId) {
rows = cursor rows = cursor
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}` ? await sql`
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND child_id = ${childId} ORDER BY created_at DESC LIMIT ${limit}`; SELECT * FROM memories
WHERE family_id = ${familyId} AND child_id = ${childId}
AND created_at < ${cursor}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`
: await sql`
SELECT * FROM memories
WHERE family_id = ${familyId} AND child_id = ${childId}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`;
} else { } else {
rows = cursor rows = cursor
? await sql`SELECT * FROM memories WHERE family_id = ${familyId} AND created_at < ${cursor} ORDER BY created_at DESC LIMIT ${limit}` ? await sql`
: await sql`SELECT * FROM memories WHERE family_id = ${familyId} ORDER BY created_at DESC LIMIT ${limit}`; SELECT * FROM memories
WHERE family_id = ${familyId}
AND created_at < ${cursor}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`
: await sql`
SELECT * FROM memories
WHERE family_id = ${familyId}
AND processing_status NOT IN ('failed')
AND (processing_status != 'uploading' OR created_at > NOW() - INTERVAL '30 minutes')
ORDER BY created_at DESC LIMIT ${limit}`;
} }
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();