a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

get email subscribers for analytics

Jared Pereira (Apr 29, 2026, 11:20 AM EDT) 3315ea3d 9e90e8cc

+43 -25
+43 -25
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 34 34 return { error: "not_found" as const }; 35 35 } 36 36 37 - let query = supabase 38 - .from("publication_subscriptions") 39 - .select("created_at") 37 + // Fetch all atproto subscriptions and confirmed email subscriptions in 38 + // parallel. We dedupe in memory (mirroring PublicationSubscribers.tsx) so 39 + // we need the full sets — date filtering is applied after. 40 + const { data: newsletterSettings } = await supabase 41 + .from("publication_newsletter_settings") 42 + .select("enabled") 40 43 .eq("publication", publication_uri) 41 - .order("created_at", { ascending: true }); 44 + .maybeSingle(); 45 + const newsletterEnabled = !!newsletterSettings?.enabled; 42 46 43 - if (from) { 44 - query = query.gte("created_at", from); 47 + const [{ data: atprotoSubs }, { data: emailSubs }] = await Promise.all([ 48 + supabase 49 + .from("publication_subscriptions") 50 + .select("created_at, identities(bsky_profiles(did))") 51 + .eq("publication", publication_uri), 52 + newsletterEnabled 53 + ? supabase 54 + .from("publication_email_subscribers") 55 + .select("id, created_at, identities(atp_did)") 56 + .eq("publication", publication_uri) 57 + .eq("state", "confirmed") 58 + : Promise.resolve({ data: [] as const }), 59 + ]); 60 + 61 + // Build dedup map keyed by DID (atproto identity) or email-sub id. 62 + // Atproto sub's created_at wins when both channels exist for the same DID, 63 + // matching the UI merge in PublicationSubscribers.tsx. 64 + const subscribers = new Map<string, string>(); 65 + for (const s of atprotoSubs || []) { 66 + const did = s.identities?.bsky_profiles?.did; 67 + if (!did) continue; 68 + subscribers.set(`did:${did}`, s.created_at); 45 69 } 46 - if (to) { 47 - query = query.lte("created_at", to); 70 + for (const s of emailSubs || []) { 71 + const linkedDid = s.identities?.atp_did ?? undefined; 72 + if (linkedDid && subscribers.has(`did:${linkedDid}`)) continue; 73 + subscribers.set(`email:${s.id}`, s.created_at); 48 74 } 49 75 50 - const { data: subscriptions } = await query; 51 - 52 - // Bucket subscriptions by day and compute cumulative count 76 + // Bucket the deduped subscribers' creation dates. 53 77 const dailyCounts: Record<string, number> = {}; 54 - for (const sub of subscriptions || []) { 55 - const day = sub.created_at.slice(0, 10); 56 - dailyCounts[day] = (dailyCounts[day] || 0) + 1; 57 - } 58 - 59 78 let cumulative = 0; 60 - 61 - // If we have a from filter, get the count of subscriptions before that date 62 - if (from) { 63 - const { count } = await supabase 64 - .from("publication_subscriptions") 65 - .select("*", { count: "exact", head: true }) 66 - .eq("publication", publication_uri) 67 - .lt("created_at", from); 68 - cumulative = count || 0; 79 + for (const createdAt of subscribers.values()) { 80 + if (from && createdAt < from) { 81 + cumulative += 1; 82 + continue; 83 + } 84 + if (to && createdAt > to) continue; 85 + const day = createdAt.slice(0, 10); 86 + dailyCounts[day] = (dailyCounts[day] || 0) + 1; 69 87 } 70 88 71 89 // Build timeseries over the full date range, filling gaps with the