Skip to content

Commit efab86e

Browse files
committed
feat: implement durable API response snapshots for Meta-heavy reads
- Add `ApiSnapshot` model to store cached responses for Instagram API calls. - Introduce snapshot management functions: `getApiSnapshot`, `setApiSnapshot`, and `buildApiSnapshotKey`. - Update Instagram profile and posts API routes to utilize snapshots for caching. - Enhance PostPicker component with manual refresh functionality and display last fetched timestamp. - Create `RefreshIcon` component for UI refresh actions. - Modify client cache hook to support bypassing server-side caches during refresh. - Implement snapshot cleanup cron job to remove expired snapshots. - Update Prisma schema and migrations to include new `ApiSnapshot` table and relationships.
1 parent 117b209 commit efab86e

17 files changed

Lines changed: 804 additions & 137 deletions

File tree

__tests__/snapshot-cleanup.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Snapshot Cleanup Cron — Unit Tests
3+
*
4+
* Covers bearer-token auth (CRON_SECRET with NEXTAUTH_SECRET fallback) and the
5+
* expired-snapshot sweep (delete anything expired more than 7 days ago).
6+
*/
7+
8+
import { beforeEach, describe, expect, it, vi } from "vitest";
9+
10+
const { mockPrisma } = vi.hoisted(() => ({
11+
mockPrisma: {
12+
apiSnapshot: {
13+
deleteMany: vi.fn(),
14+
},
15+
},
16+
}));
17+
18+
vi.mock("@/lib/db/client", () => ({
19+
prisma: mockPrisma,
20+
}));
21+
22+
import { GET } from "../app/api/cron/snapshot-cleanup/route";
23+
24+
const KEEP_EXPIRED_FOR_DAYS = 7;
25+
const CRON_SECRET = "cron_secret_123";
26+
const NEXTAUTH_SECRET = "nextauth_secret_456";
27+
const DAY_MS = 24 * 60 * 60 * 1000;
28+
29+
function buildRequest(authorization?: string): Parameters<typeof GET>[0] {
30+
return new Request("https://app.example.com/api/cron/snapshot-cleanup", {
31+
headers: authorization ? { authorization } : undefined,
32+
}) as Parameters<typeof GET>[0];
33+
}
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
vi.unstubAllEnvs();
38+
mockPrisma.apiSnapshot.deleteMany.mockResolvedValue({ count: 0 });
39+
});
40+
41+
describe("snapshot cleanup cron", () => {
42+
it("rejects requests without an authorization header", async () => {
43+
const response = await GET(buildRequest());
44+
45+
expect(response.status).toBe(401);
46+
await expect(response.json()).resolves.toEqual({
47+
success: false,
48+
error: "Unauthorized",
49+
});
50+
expect(mockPrisma.apiSnapshot.deleteMany).not.toHaveBeenCalled();
51+
});
52+
53+
it("rejects requests with the wrong bearer token", async () => {
54+
vi.stubEnv("CRON_SECRET", CRON_SECRET);
55+
56+
const response = await GET(buildRequest("Bearer wrong_token"));
57+
58+
expect(response.status).toBe(401);
59+
expect(mockPrisma.apiSnapshot.deleteMany).not.toHaveBeenCalled();
60+
});
61+
62+
it("sweeps snapshots expired more than 7 days ago and reports the count", async () => {
63+
vi.stubEnv("CRON_SECRET", CRON_SECRET);
64+
mockPrisma.apiSnapshot.deleteMany.mockResolvedValue({ count: 5 });
65+
66+
const before = Date.now();
67+
const response = await GET(buildRequest(`Bearer ${CRON_SECRET}`));
68+
69+
expect(response.status).toBe(200);
70+
const body = await response.json();
71+
expect(body.success).toBe(true);
72+
expect(body.data.deleted).toBe(5);
73+
74+
// One sweep, cut off at now minus 7 days (within a small tolerance for
75+
// the time that elapses while the test runs).
76+
expect(mockPrisma.apiSnapshot.deleteMany).toHaveBeenCalledTimes(1);
77+
const cutoff = mockPrisma.apiSnapshot.deleteMany.mock
78+
.calls[0][0] as { where: { expiresAt: { lt: Date } } };
79+
expect(cutoff.where.expiresAt.lt).toBeInstanceOf(Date);
80+
81+
const cutoffMs = cutoff.where.expiresAt.lt.getTime();
82+
const expectedMs = before - KEEP_EXPIRED_FOR_DAYS * DAY_MS;
83+
expect(Math.abs(cutoffMs - expectedMs)).toBeLessThan(5000);
84+
85+
// The reported cutoff round-trips the exact value used in the query.
86+
expect(body.data.cutoff).toBe(cutoff.where.expiresAt.lt.toISOString());
87+
});
88+
89+
it("falls back to NEXTAUTH_SECRET when CRON_SECRET is not set", async () => {
90+
vi.stubEnv("CRON_SECRET", "");
91+
vi.stubEnv("NEXTAUTH_SECRET", NEXTAUTH_SECRET);
92+
93+
const response = await GET(buildRequest(`Bearer ${NEXTAUTH_SECRET}`));
94+
95+
expect(response.status).toBe(200);
96+
expect(mockPrisma.apiSnapshot.deleteMany).toHaveBeenCalledTimes(1);
97+
});
98+
99+
it("returns success with zero deletions when nothing is expired", async () => {
100+
vi.stubEnv("CRON_SECRET", CRON_SECRET);
101+
mockPrisma.apiSnapshot.deleteMany.mockResolvedValue({ count: 0 });
102+
103+
const response = await GET(buildRequest(`Bearer ${CRON_SECRET}`));
104+
105+
expect(response.status).toBe(200);
106+
const body = await response.json();
107+
expect(body).toMatchObject({ success: true, data: { deleted: 0 } });
108+
});
109+
});

app/(dashboard)/inbox/page.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ export default function InboxPage() {
287287
) : convError ? (
288288
<p className="px-4 py-6 text-sm text-error">{convError}</p>
289289
) : conversations.length === 0 ? (
290-
<p className="px-4 py-6 text-sm text-muted">No conversations yet.</p>
290+
<p className="px-4 py-6 text-sm text-muted">
291+
No conversations yet.
292+
</p>
291293
) : (
292294
conversations.map((c) => {
293295
const isActive = c.id === activeId;
@@ -324,7 +326,7 @@ export default function InboxPage() {
324326
{/* Thread. On mobile it is only shown once a conversation is open and
325327
fills the pane; on sm+ it always sits beside the list. */}
326328
<div
327-
className={`min-h-0 flex-col ${active ? "flex" : "hidden sm:flex"}`}
329+
className={`min-h-0 flex-col w-[70%] ${active ? "flex" : "hidden sm:flex"}`}
328330
>
329331
{!active ? (
330332
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted">
@@ -346,7 +348,10 @@ export default function InboxPage() {
346348
</span>
347349
</div>
348350

349-
<div ref={scrollRef} className="min-h-0 flex-1 space-y-2 overflow-y-auto p-4">
351+
<div
352+
ref={scrollRef}
353+
className="min-h-0 flex-1 space-y-2 overflow-y-auto p-4"
354+
>
350355
{threadLoading && messages.length === 0 ? (
351356
<p className="text-sm text-muted">Loading…</p>
352357
) : messages.length === 0 ? (
@@ -364,7 +369,9 @@ export default function InboxPage() {
364369
: "bg-surface text-foreground border border-border"
365370
}`}
366371
>
367-
<p className="whitespace-pre-wrap break-words">{m.text}</p>
372+
<p className="whitespace-pre-wrap wrap-break-word">
373+
{m.text}
374+
</p>
368375
<p
369376
className={`mt-1 text-[10px] ${
370377
m.fromMe ? "text-white/70" : "text-zinc-500"
@@ -389,7 +396,7 @@ export default function InboxPage() {
389396
onKeyDown={handleKeyDown}
390397
rows={1}
391398
placeholder="Write a reply… (Enter to send, Shift+Enter for a new line)"
392-
className="max-h-32 min-h-[40px] flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none"
399+
className="max-h-32 min-h-10 flex-1 resize-none rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none"
393400
/>
394401
<button
395402
type="button"

app/(dashboard)/overview/page.tsx

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
* the insights permission); likes and comments are always available.
99
*/
1010

11-
import { useState } from "react";
11+
import { useEffect, useRef, useState } from "react";
1212
import dynamic from "next/dynamic";
1313
import AccountSelect from "@/components/account-select";
14+
import RefreshIcon from "@/components/refresh-icon";
1415
import StatCard from "@/components/stat-card";
1516
import { useCachedFetch } from "@/lib/client-cache";
17+
import { formatTimeAgo } from "@/lib/utils/time";
1618
import type { OverviewResponse } from "@/app/api/instagram/overview/route";
1719

1820
// recharts is heavy (~100KB+ gzip); keep it out of the initial bundle and
@@ -46,25 +48,56 @@ const COUNT_OPTIONS = [
4648
export default function OverviewPage() {
4749
const [selectedAccountId, setSelectedAccountId] = useState("all");
4850
const [count, setCount] = useState("50");
51+
// Snapshot freshness reported by the API (snapshot.fetchedAt) — surfaces
52+
// when the underlying Instagram data was last pulled from Meta.
53+
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
54+
const [refreshing, setRefreshing] = useState(false);
55+
56+
// Identifies the active account/range selection. Shared by the ref below and
57+
// the fetcher so a response can tell whether it still belongs to the current
58+
// selection (single source of truth — they can't drift apart).
59+
const requestKey = `overview:${selectedAccountId}:${count}`;
60+
61+
// Always the latest selection, so async completions can tell whether the
62+
// response they belong to is still the active one.
63+
const requestKeyRef = useRef("");
64+
useEffect(() => {
65+
requestKeyRef.current = requestKey;
66+
});
4967

5068
// The overview is backed by the Meta Graph API, so it is the slowest page.
5169
// Cached copies paint instantly on return visits and background refetches
52-
// (max age 60s — Instagram insights don't move faster than that).
70+
// (max age 60s — Instagram insights don't move faster than that). Manual
71+
// refresh passes `bypass=true`, which re-fetches straight from Meta.
5372
const overviewFetch = useCachedFetch<OverviewResponse>(
5473
`dash:overview:${selectedAccountId}:${count}`,
55-
async () => {
74+
async (bypass = false) => {
5675
const params = new URLSearchParams();
5776
if (selectedAccountId !== "all") {
5877
params.set("instagramAccountId", selectedAccountId);
5978
}
6079
params.set("count", count);
80+
if (bypass) params.set("refresh", "true");
6181

62-
const res = await fetch(`/api/instagram/overview?${params}`);
63-
const payload = await res.json();
64-
if (!payload.success) {
65-
throw new Error(payload.error ?? "Failed to load overview");
82+
if (bypass) setRefreshing(true);
83+
try {
84+
const res = await fetch(`/api/instagram/overview?${params}`);
85+
const payload = await res.json();
86+
if (!payload.success) {
87+
throw new Error(payload.error ?? "Failed to load overview");
88+
}
89+
if (
90+
payload.snapshot?.fetchedAt &&
91+
requestKey === requestKeyRef.current
92+
) {
93+
setLastFetchedAt(payload.snapshot.fetchedAt as string);
94+
}
95+
return payload.data as OverviewResponse;
96+
} finally {
97+
// Transient UI flag — safe to clear even for a stale request, since a
98+
// stale request means the current selection is no longer refreshing.
99+
if (bypass) setRefreshing(false);
66100
}
67-
return payload.data as OverviewResponse;
68101
},
69102
{ maxAgeMs: 60_000 }
70103
);
@@ -73,10 +106,14 @@ export default function OverviewPage() {
73106

74107
function handleAccountChange(accountId: string) {
75108
setSelectedAccountId(accountId);
109+
// The old selection's freshness no longer applies; hide it until the new
110+
// selection's fetch reports its snapshot time.
111+
setLastFetchedAt(null);
76112
}
77113

78114
function handleCountChange(next: string) {
79115
setCount(next);
116+
setLastFetchedAt(null);
80117
}
81118

82119
if (overviewFetch.loading && !data) {
@@ -132,8 +169,23 @@ export default function OverviewPage() {
132169
{followers.toLocaleString()} followers
133170
</p>
134171
)}
172+
{lastFetchedAt && (
173+
<p className="mt-1 text-xs text-muted/80">
174+
Last refreshed {formatTimeAgo(lastFetchedAt)}
175+
</p>
176+
)}
135177
</div>
136178
<div className="flex flex-wrap items-end gap-x-4 gap-y-3">
179+
<button
180+
type="button"
181+
onClick={() => overviewFetch.refresh(true)}
182+
disabled={refreshing}
183+
title="Refresh from Instagram"
184+
aria-label="Refresh from Instagram"
185+
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-surface text-muted transition hover:border-border-hover hover:text-foreground disabled:opacity-50"
186+
>
187+
<RefreshIcon className={refreshing ? "animate-spin" : ""} />
188+
</button>
137189
<label className="flex flex-col gap-2 text-sm">
138190
<span className="text-xs font-semibold uppercase tracking-wide text-zinc-500">
139191
Range
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { prisma } from "@/lib/db/client";
3+
4+
/**
5+
* Sweeps expired API snapshots.
6+
*
7+
* Snapshots are overwritten in place by stable keys, so growth is already
8+
* bounded — but a deleted account, a changed key scheme, or a failed upsert
9+
* can still orphan rows. Anything that expired more than this long ago is
10+
* unrecoverable garbage and can be removed.
11+
*/
12+
const KEEP_EXPIRED_FOR_DAYS = 7;
13+
14+
export async function GET(request: NextRequest) {
15+
const authHeader = request.headers.get("authorization");
16+
const cronSecret = process.env.CRON_SECRET || process.env.NEXTAUTH_SECRET;
17+
18+
if (authHeader !== `Bearer ${cronSecret}`) {
19+
return NextResponse.json(
20+
{ success: false, error: "Unauthorized" },
21+
{ status: 401 }
22+
);
23+
}
24+
25+
const cutoff = new Date();
26+
cutoff.setDate(cutoff.getDate() - KEEP_EXPIRED_FOR_DAYS);
27+
28+
const { count } = await prisma.apiSnapshot.deleteMany({
29+
where: { expiresAt: { lt: cutoff } },
30+
});
31+
32+
return NextResponse.json({
33+
success: true,
34+
data: {
35+
deleted: count,
36+
cutoff: cutoff.toISOString(),
37+
},
38+
});
39+
}

0 commit comments

Comments
 (0)