Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

push-notify.tsBlame197 lines · 1 contributor
a2b3e99Claude1/**
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
21import { eq } from "drizzle-orm";
22import {
23 pgTable,
24 uuid,
25 text,
26 timestamp,
27 uniqueIndex,
28 index,
29} from "drizzle-orm/pg-core";
30import { db } from "../db";
31import {
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
41export 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
63export type PushSubscriptionInput = {
64 endpoint: string;
65 keys: { p256dh: string; auth: string };
66};
67
68export type PushSubscriptionRow = typeof pushSubscriptions.$inferSelect;
69
70export 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 */
85export 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 */
97export 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 */
110export 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 */
136export 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. */
152export 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. */
164export 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. */
176export 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. */
188export 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}