Skip to content

Commit 6436058

Browse files
committed
feat: add public notes caching and public count to notes endpoint
Public Notes Two-Tier Caching: - Add Cloudflare CDN caching (24h TTL) for GET /api/public-notes/:slug - Add Upstash Redis caching layer (24h TTL) between CDN and database - Invalidate both caches on PUT/DELETE operations - Add cloudflare-cache.ts helper for CDN purge via Cloudflare API Public Count Feature: - Add public count field to GET /api/notes/counts response - Shows count of published notes (excludes archived/deleted) - Joins with public_notes table to determine published status - Update OpenAPI schemas with proper types (replaces z.any()) Environment variables required: - CLOUDFLARE_ZONE_ID (for CDN purging) - CLOUDFLARE_API_TOKEN (for CDN purging) - PUBLIC_URL (for constructing purge URLs)
1 parent 8c210d0 commit 6436058

File tree

5 files changed

+246
-57
lines changed

5 files changed

+246
-57
lines changed

src/lib/cache-keys.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const CacheKeys = {
1919

2020
// Attachment-related
2121
noteAttachments: (noteId: string) => `attachments:note:${noteId}`,
22+
23+
// Public notes (no auth required, cached by slug)
24+
publicNote: (slug: string) => `public:note:${slug}`,
2225
} as const;
2326

2427
// Cache TTL values (in seconds)
@@ -34,4 +37,5 @@ export const CacheTTL = {
3437
notesDeleted: 300, // 5 minutes
3538
notesCounts: 120, // 2 minutes
3639
noteAttachments: 1800, // 30 minutes
40+
publicNote: 86400, // 24 hours (same as Cloudflare CDN)
3741
} as const;

src/lib/cloudflare-cache.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Cloudflare CDN Cache Utilities
3+
* Handles cache purging for public content via Cloudflare API
4+
*/
5+
6+
import { logger } from "./logger";
7+
8+
interface CloudflarePurgeResponse {
9+
success: boolean;
10+
errors: Array<{ code: number; message: string }>;
11+
messages: string[];
12+
result: { id: string } | null;
13+
}
14+
15+
/**
16+
* Check if Cloudflare cache purging is configured
17+
*/
18+
function isCloudflareConfigured(): boolean {
19+
return !!(
20+
process.env.CLOUDFLARE_ZONE_ID &&
21+
process.env.CLOUDFLARE_API_TOKEN &&
22+
process.env.PUBLIC_URL
23+
);
24+
}
25+
26+
/**
27+
* Purge specific URLs from Cloudflare CDN cache
28+
* @param urls - Array of full URLs to purge
29+
* @returns true if purge was successful (or Cloudflare not configured)
30+
*/
31+
export async function purgeCloudflareCache(urls: string[]): Promise<boolean> {
32+
if (!isCloudflareConfigured()) {
33+
logger.debug("[CLOUDFLARE] Cache purge skipped - not configured", {
34+
urlCount: urls.length,
35+
});
36+
return true; // Not an error, just not configured
37+
}
38+
39+
if (urls.length === 0) {
40+
return true;
41+
}
42+
43+
const zoneId = process.env.CLOUDFLARE_ZONE_ID!;
44+
const apiToken = process.env.CLOUDFLARE_API_TOKEN!;
45+
46+
try {
47+
const startTime = Date.now();
48+
49+
const response = await fetch(
50+
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
51+
{
52+
method: "POST",
53+
headers: {
54+
Authorization: `Bearer ${apiToken}`,
55+
"Content-Type": "application/json",
56+
},
57+
body: JSON.stringify({ files: urls }),
58+
}
59+
);
60+
61+
const duration = Date.now() - startTime;
62+
const data: CloudflarePurgeResponse = await response.json();
63+
64+
if (data.success) {
65+
logger.info("[CLOUDFLARE] Cache purged successfully", {
66+
type: "cloudflare_cache_event",
67+
event_type: "cache_purged",
68+
urls: urls.join(", "),
69+
urlCount: urls.length,
70+
duration,
71+
});
72+
return true;
73+
} else {
74+
logger.error("[CLOUDFLARE] Cache purge failed", {
75+
type: "cloudflare_cache_event",
76+
event_type: "cache_purge_failed",
77+
urls: urls.join(", "),
78+
errors: JSON.stringify(data.errors),
79+
duration,
80+
});
81+
return false;
82+
}
83+
} catch (error) {
84+
logger.error(
85+
"[CLOUDFLARE] Cache purge request failed",
86+
{
87+
type: "cloudflare_cache_event",
88+
event_type: "cache_purge_error",
89+
urls: urls.join(", "),
90+
},
91+
error instanceof Error ? error : new Error(String(error))
92+
);
93+
return false;
94+
}
95+
}
96+
97+
/**
98+
* Purge a public note from Cloudflare CDN cache
99+
* @param slug - The public note slug
100+
*/
101+
export async function purgePublicNoteCache(slug: string): Promise<boolean> {
102+
const publicUrl = process.env.PUBLIC_URL;
103+
if (!publicUrl) {
104+
logger.debug("[CLOUDFLARE] PUBLIC_URL not configured, skipping cache purge");
105+
return true;
106+
}
107+
108+
// Purge both the API endpoint and any potential frontend route
109+
const urls = [
110+
`${publicUrl}/api/public-notes/${slug}`,
111+
`${publicUrl}/p/${slug}`, // Frontend route if exists
112+
];
113+
114+
return purgeCloudflareCache(urls);
115+
}
116+
117+
/**
118+
* Get Cache-Control header value for public notes
119+
* Uses stale-while-revalidate for better UX
120+
*/
121+
export function getPublicNoteCacheHeaders(): Record<string, string> {
122+
// Cache for 24 hours, allow stale content for 1 hour while revalidating
123+
// Long TTL is fine since we purge cache on update/unpublish
124+
return {
125+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=3600",
126+
// Vary on Accept-Encoding for proper compression handling
127+
Vary: "Accept-Encoding",
128+
};
129+
}

src/lib/openapi-schemas.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export const reorderFolderResponseSchema = z
255255
const countsObjectSchema = z.object({
256256
all: z.number().openapi({ example: 10, description: "Active notes count" }),
257257
starred: z.number().openapi({ example: 2, description: "Starred notes count" }),
258+
public: z.number().openapi({ example: 1, description: "Published notes count" }),
258259
archived: z.number().openapi({ example: 1, description: "Archived notes count" }),
259260
trash: z.number().openapi({ example: 0, description: "Deleted notes count" }),
260261
});
@@ -263,12 +264,25 @@ export const noteCountsSchema = z
263264
.object({
264265
all: z.number().openapi({ example: 42, description: "Total active notes count" }),
265266
starred: z.number().openapi({ example: 5, description: "Total starred notes count" }),
267+
public: z.number().openapi({ example: 3, description: "Total published notes count" }),
266268
archived: z.number().openapi({ example: 12, description: "Total archived notes count" }),
267269
trash: z.number().openapi({ example: 3, description: "Total deleted notes count" }),
268270
folders: z.record(z.string(), countsObjectSchema).openapi({
269271
example: {
270-
"123e4567-e89b-12d3-a456-426614174000": { all: 10, starred: 2, archived: 1, trash: 0 },
271-
"223e4567-e89b-12d3-a456-426614174001": { all: 5, starred: 1, archived: 0, trash: 0 },
272+
"123e4567-e89b-12d3-a456-426614174000": {
273+
all: 10,
274+
starred: 2,
275+
public: 1,
276+
archived: 1,
277+
trash: 0,
278+
},
279+
"223e4567-e89b-12d3-a456-426614174001": {
280+
all: 5,
281+
starred: 1,
282+
public: 0,
283+
archived: 0,
284+
trash: 0,
285+
},
272286
},
273287
description:
274288
"Counts for each root-level folder (folder IDs as keys, includes descendant notes)",

0 commit comments

Comments
 (0)