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:
parent
9c2e7328ab
commit
ccae6d85d2
5 changed files with 116 additions and 25 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue