Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/server/kv-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* KV schema definitions for the CF Worker tracking pixel system.
*
* Key design principles:
* - All tracking keys share the `tracking:` prefix for namespace isolation
* - Summary records aggregate per-recipient data under a single KV key per campaign
* (one read to get all opens for a campaign — efficient for listing)
* - 90-day TTL keeps KV clean without manual pruning
*
* Key patterns:
*
* tracking:summary:{campaignId}
* → TrackingSummary (JSON)
* → Contains all recipient open events for the campaign
* → Supports prefix scan via KV list({ prefix: 'tracking:summary:' })
*
* Example queries:
* - All opens for campaign "q1-launch": kv.get('tracking:summary:q1-launch')
* - All campaigns (paginated): kv.list({ prefix: 'tracking:summary:' })
*/

// ─── Key Helpers ─────────────────────────────────────────────

/** Namespace prefix for all tracking keys. */
export const TRACKING_PREFIX = 'tracking:' as const;

/** Sub-prefix for campaign summary records. */
export const SUMMARY_PREFIX = `${TRACKING_PREFIX}summary:` as const;

/**
* Returns the KV key for a campaign's summary record.
* Key: `tracking:summary:{campaignId}`
*/
export function summaryKey(campaignId: string): string {
return `${SUMMARY_PREFIX}${campaignId}`;
}

// ─── TTL ─────────────────────────────────────────────────────

/** Default TTL for tracking records: 90 days in seconds. */
export const TRACKING_TTL_SECONDS = 90 * 24 * 60 * 60; // 7_776_000

// ─── KV Value Types ──────────────────────────────────────────

/**
* Per-recipient open tracking data.
* Stored as a nested value inside TrackingSummary.recipients.
*/
export interface RecipientTracking {
/** Total number of pixel requests from this recipient. */
openCount: number;
/** ISO 8601 timestamp of the first pixel request. */
firstOpenedAt: string;
/** ISO 8601 timestamp of the most recent pixel request. */
lastOpenedAt: string;
}

/**
* Summary record for a single campaign.
* Stored at `tracking:summary:{campaignId}` as a JSON string.
*
* Schema version: 1
*/
export interface TrackingSummary {
/** Campaign identifier matching the URL segment. */
campaignId: string;
/** Total pixel requests across all recipients (includes re-opens). */
totalOpens: number;
/** Number of distinct recipients who opened at least once. */
uniqueOpens: number;
/**
* Per-recipient tracking data.
* Key: recipientId (URL segment).
* Value: RecipientTracking.
*/
recipients: Record<string, RecipientTracking>;
/** ISO 8601 timestamp of the last write to this record. */
updatedAt: string;
}

// ─── KV Interface ────────────────────────────────────────────

/**
* Minimal KV interface used by the tracking module.
* Compatible with Cloudflare Workers KVNamespace.
*/
export interface KVLike {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
}
72 changes: 29 additions & 43 deletions src/server/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,48 +5,21 @@
* - Serves a 1x1 transparent GIF
* - Writes open events to KV with summary record pattern
*
* KV key pattern: tracking:summary:{campaignId}
* TTL: 90 days (7_776_000 seconds)
*
* No googleapis imports — this module only uses KV.
* See kv-schema.ts for the full KV schema documentation.
*/

// ─── Types ───────────────────────────────────────────────────

/** Minimal KV interface compatible with CF Workers KVNamespace. */
export interface KVLike {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
}

/** Per-recipient tracking data stored within a campaign summary. */
export interface RecipientTracking {
openCount: number;
firstOpenedAt: string;
lastOpenedAt: string;
}
export type {
KVLike,
RecipientTracking,
TrackingSummary,
} from './kv-schema.js';

/** Summary record stored in KV for each campaign. */
export interface TrackingSummary {
campaignId: string;
totalOpens: number;
uniqueOpens: number;
recipients: Record<string, RecipientTracking>;
updatedAt: string;
}

/** Result type for getTrackingData(). */
export interface TrackingDataResult {
campaignId: string;
totalOpens: number;
uniqueOpens: number;
recipients: Array<{
recipientId: string;
openCount: number;
firstOpenedAt: string;
lastOpenedAt: string;
}>;
}
import {
summaryKey,
TRACKING_TTL_SECONDS,
type KVLike,
type TrackingSummary,
} from './kv-schema.js';

// ─── Constants ───────────────────────────────────────────────

Expand All @@ -65,7 +38,20 @@ export const TRANSPARENT_GIF = new Uint8Array([
0x3b, // Trailer
]);

const KV_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days = 7_776_000s
// ─── Result Type ─────────────────────────────────────────────

/** Result type for getTrackingData(). */
export interface TrackingDataResult {
campaignId: string;
totalOpens: number;
uniqueOpens: number;
recipients: Array<{
recipientId: string;
openCount: number;
firstOpenedAt: string;
lastOpenedAt: string;
}>;
}

// ─── URL Parsing ─────────────────────────────────────────────

Expand Down Expand Up @@ -121,7 +107,7 @@ export async function handleTrackingRequest(

const { campaignId, recipientId } = params;
const now = new Date().toISOString();
const kvKey = `tracking:summary:${campaignId}`;
const kvKey = summaryKey(campaignId);

// Read existing summary (or start fresh)
// NOTE: This is a read-modify-write on KV. Two concurrent pixel hits may read
Expand Down Expand Up @@ -157,7 +143,7 @@ export async function handleTrackingRequest(
summary.updatedAt = now;

// Write back to KV with 90-day TTL
await kv.put(kvKey, JSON.stringify(summary), { expirationTtl: KV_TTL_SECONDS });
await kv.put(kvKey, JSON.stringify(summary), { expirationTtl: TRACKING_TTL_SECONDS });

// Return the transparent GIF
return new Response(TRANSPARENT_GIF, {
Expand All @@ -181,7 +167,7 @@ export async function getTrackingData(
kv: KVLike
): Promise<TrackingDataResult> {
const { campaignId } = opts;
const raw = await kv.get(`tracking:summary:${campaignId}`);
const raw = await kv.get(summaryKey(campaignId));

if (!raw) {
return {
Expand Down
Loading