CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
push-notify.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| a2b3e99 | 1 | /** |
| 2 | * push-notify.ts — Block M2 addendum. | |
| 3 | * | |
| 4 | * Higher-level push notification helpers for the four developer-facing events: | |
| 5 | * deploy_success, gate_failed, pr_merged, ai_review | |
| 6 | * | |
| 7 | * This module re-exports the lower-level subscription CRUD from src/lib/push.ts | |
| 8 | * under a clean, event-oriented surface so route handlers and post-receive hooks | |
| 9 | * can call a single function without importing from two places. | |
| 10 | * | |
| 11 | * The Drizzle table definition is declared inline here (schema.ts is locked) | |
| 12 | * and mirrors the table already created by drizzle/0076_push_subscriptions.sql. | |
| 13 | * Both definitions share the same underlying table name so the DB only sees one | |
| 14 | * physical table. | |
| 15 | * | |
| 16 | * VAPID is handled transparently by push.ts (env-first, process-cached fallback). | |
| 17 | * No `web-push` npm package is required — the implementation uses pure Web Crypto | |
| 18 | * (RFC 8291 / RFC 8188 / RFC 8292) via Bun's built-in crypto.subtle. | |
| 19 | */ | |
| 20 | ||
| 21 | import { eq } from "drizzle-orm"; | |
| 22 | import { | |
| 23 | pgTable, | |
| 24 | uuid, | |
| 25 | text, | |
| 26 | timestamp, | |
| 27 | uniqueIndex, | |
| 28 | index, | |
| 29 | } from "drizzle-orm/pg-core"; | |
| 30 | import { db } from "../db"; | |
| 31 | import { | |
| 32 | subscribeUser as _subscribeUser, | |
| 33 | unsubscribeUser as _unsubscribeUser, | |
| 34 | sendPushToUser, | |
| 35 | } from "./push"; | |
| 36 | ||
| 37 | // --------------------------------------------------------------------------- | |
| 38 | // Inline table definition (mirrors schema.ts — locked) | |
| 39 | // --------------------------------------------------------------------------- | |
| 40 | ||
| 41 | export const pushSubscriptions = pgTable( | |
| 42 | "push_subscriptions", | |
| 43 | { | |
| 44 | id: uuid("id").primaryKey().defaultRandom(), | |
| 45 | userId: uuid("user_id").notNull(), | |
| 46 | endpoint: text("endpoint").notNull(), | |
| 47 | p256dh: text("p256dh").notNull(), | |
| 48 | auth: text("auth").notNull(), | |
| 49 | userAgent: text("user_agent"), | |
| 50 | createdAt: timestamp("created_at").defaultNow().notNull(), | |
| 51 | lastUsedAt: timestamp("last_used_at"), | |
| 52 | }, | |
| 53 | (t) => [ | |
| 54 | uniqueIndex("push_subs_endpoint_uq").on(t.endpoint), | |
| 55 | index("push_subs_user_idx").on(t.userId), | |
| 56 | ] | |
| 57 | ); | |
| 58 | ||
| 59 | // --------------------------------------------------------------------------- | |
| 60 | // Types | |
| 61 | // --------------------------------------------------------------------------- | |
| 62 | ||
| 63 | export type PushSubscriptionInput = { | |
| 64 | endpoint: string; | |
| 65 | keys: { p256dh: string; auth: string }; | |
| 66 | }; | |
| 67 | ||
| 68 | export type PushSubscriptionRow = typeof pushSubscriptions.$inferSelect; | |
| 69 | ||
| 70 | export type PushPayload = { | |
| 71 | title: string; | |
| 72 | body: string; | |
| 73 | url?: string; | |
| 74 | }; | |
| 75 | ||
| 76 | // --------------------------------------------------------------------------- | |
| 77 | // CRUD helpers | |
| 78 | // --------------------------------------------------------------------------- | |
| 79 | ||
| 80 | /** | |
| 81 | * Persist a push subscription for a user. Safe to call repeatedly — the | |
| 82 | * underlying insert uses ON CONFLICT DO UPDATE so keys are refreshed on | |
| 83 | * browser-side rotation. | |
| 84 | */ | |
| 85 | export async function savePushSubscription( | |
| 86 | userId: string, | |
| 87 | subscription: PushSubscriptionInput, | |
| 88 | userAgent?: string | |
| 89 | ): Promise<void> { | |
| 90 | return _subscribeUser(userId, subscription, userAgent); | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Remove a push subscription by endpoint. No-ops gracefully when the | |
| 95 | * endpoint is not found. | |
| 96 | */ | |
| 97 | export async function deletePushSubscription(endpoint: string): Promise<void> { | |
| 98 | try { | |
| 99 | await db | |
| 100 | .delete(pushSubscriptions) | |
| 101 | .where(eq(pushSubscriptions.endpoint, endpoint)); | |
| 102 | } catch (err) { | |
| 103 | console.error("[push-notify] deletePushSubscription failed:", err); | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /** | |
| 108 | * List all push subscriptions for a user, newest first. | |
| 109 | */ | |
| 110 | export async function listPushSubscriptions( | |
| 111 | userId: string | |
| 112 | ): Promise<PushSubscriptionRow[]> { | |
| 113 | try { | |
| 114 | return await db | |
| 115 | .select() | |
| 116 | .from(pushSubscriptions) | |
| 117 | .where(eq(pushSubscriptions.userId, userId)) | |
| 118 | .orderBy(pushSubscriptions.createdAt); | |
| 119 | } catch (err) { | |
| 120 | console.error("[push-notify] listPushSubscriptions failed:", err); | |
| 121 | return []; | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | // --------------------------------------------------------------------------- | |
| 126 | // High-level fan-out | |
| 127 | // --------------------------------------------------------------------------- | |
| 128 | ||
| 129 | /** | |
| 130 | * Send a push notification to every subscription owned by a user. | |
| 131 | * Stale endpoints (HTTP 410/404) are cleaned up automatically by the | |
| 132 | * underlying sendPushToUser transport layer. | |
| 133 | * | |
| 134 | * Returns { sent, failed } counts; never throws. | |
| 135 | */ | |
| 136 | export async function sendPushNotification( | |
| 137 | userId: string, | |
| 138 | payload: PushPayload | |
| 139 | ): Promise<{ sent: number; failed: number }> { | |
| 140 | return sendPushToUser(userId, { | |
| 141 | title: payload.title, | |
| 142 | body: payload.body, | |
| 143 | url: payload.url, | |
| 144 | }); | |
| 145 | } | |
| 146 | ||
| 147 | // --------------------------------------------------------------------------- | |
| 148 | // Typed event helpers — thin wrappers for the four tracked developer events | |
| 149 | // --------------------------------------------------------------------------- | |
| 150 | ||
| 151 | /** Notify a developer that their push deployed successfully. */ | |
| 152 | export async function notifyDeploySuccess( | |
| 153 | userId: string, | |
| 154 | opts: { repoFullName: string; sha: string; url?: string } | |
| 155 | ): Promise<{ sent: number; failed: number }> { | |
| 156 | return sendPushNotification(userId, { | |
| 157 | title: "Deploy succeeded", | |
| 158 | body: `${opts.repoFullName} @ ${opts.sha.slice(0, 7)} is live.`, | |
| 159 | url: opts.url, | |
| 160 | }); | |
| 161 | } | |
| 162 | ||
| 163 | /** Notify a developer that a gate check failed on their push. */ | |
| 164 | export async function notifyGateFailed( | |
| 165 | userId: string, | |
| 166 | opts: { repoFullName: string; sha: string; gate: string; url?: string } | |
| 167 | ): Promise<{ sent: number; failed: number }> { | |
| 168 | return sendPushNotification(userId, { | |
| 169 | title: `Gate failed: ${opts.gate}`, | |
| 170 | body: `${opts.repoFullName} @ ${opts.sha.slice(0, 7)}`, | |
| 171 | url: opts.url, | |
| 172 | }); | |
| 173 | } | |
| 174 | ||
| 175 | /** Notify a developer that their PR was merged. */ | |
| 176 | export async function notifyPrMerged( | |
| 177 | userId: string, | |
| 178 | opts: { repoFullName: string; prNumber: number; title: string; url?: string } | |
| 179 | ): Promise<{ sent: number; failed: number }> { | |
| 180 | return sendPushNotification(userId, { | |
| 181 | title: `PR #${opts.prNumber} merged`, | |
| 182 | body: `${opts.repoFullName}: ${opts.title}`, | |
| 183 | url: opts.url, | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | /** Notify a developer that an AI review was posted on their PR. */ | |
| 188 | export async function notifyAiReview( | |
| 189 | userId: string, | |
| 190 | opts: { repoFullName: string; prNumber: number; url?: string } | |
| 191 | ): Promise<{ sent: number; failed: number }> { | |
| 192 | return sendPushNotification(userId, { | |
| 193 | title: "AI review posted", | |
| 194 | body: `New AI review on ${opts.repoFullName} PR #${opts.prNumber}`, | |
| 195 | url: opts.url, | |
| 196 | }); | |
| 197 | } |