/ opencodehub / src / lib / webhooks.ts
swadhinbiswas Fix: Add date validation in analysis.ts to prevent RangeError · 1/20/2026
a7662cd
154 lines · 4.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
/**
 * Webhook Dispatch Service
 * Handles triggering and delivering webhooks
 */

import crypto from "crypto";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { getDatabase, schema } from "@/db";
import { generateId } from "./utils";
import { eq, and } from "drizzle-orm";
import { logger } from "./logger";

interface WebhookPayload {
    [key: string]: any;
}

/**
 * Trigger webhooks for a specific repository and event
 */
export async function triggerWebhooks(
    repositoryId: string,
    event: string,
    payload: WebhookPayload
): Promise<void> {
    const db = getDatabase() as NodePgDatabase<typeof schema>;

    // Find active webhooks for this repo
    const webhooks = await db.query.webhooks.findMany({
        where: and(
            eq(schema.webhooks.repositoryId, repositoryId),
            eq(schema.webhooks.active, true)
        ),
    });

    // Filter webhooks that subscribe to this event
    const matchingWebhooks = webhooks.filter((hook) => {
        const events = Array.isArray(hook.events) ? hook.events : JSON.parse(hook.events as any);
        return events.includes(event) || events.includes("*");
    });

    if (matchingWebhooks.length === 0) return;

    logger.info(
        { repositoryId, event, webhooks: matchingWebhooks.length },
        "Triggering webhooks"
    );

    // Dispatch to all matching webhooks (fire and forget)
    // In a real system, this would go to a job queue (BullMQ/Redis)
    // For now, we'll run it asynchronously
    matchingWebhooks.forEach((hook) => {
        dispatchWebhook(hook, event, payload).catch((err) => {
            logger.error({ webhookId: hook.id, err }, "Failed to dispatch webhook");
        });
    });
}

/**
 * Dispatch a single webhook
 */
async function dispatchWebhook(
    webhook: typeof schema.webhooks.$inferSelect,
    event: string,
    payload: WebhookPayload
): Promise<void> {
    const db = getDatabase() as NodePgDatabase<typeof schema>;
    const deliveryId = generateId();
    const startTime = Date.now();

    try {
        // Generate signature
        const signature = signPayload(payload, webhook.secret || "");

        // Prepare headers
        const headers: Record<string, string> = {
            "Content-Type": "application/json",
            "User-Agent": "OpenCodeHub-Hookshot/1.0",
            "X-OpenCodeHub-Event": event,
            "X-OpenCodeHub-Delivery": deliveryId,
            "X-Hub-Signature-256": `sha256=${signature}`,
        };

        // Make request
        const response = await fetch(webhook.url, {
            method: "POST",
            headers,
            body: JSON.stringify(payload),
        });

        const durationMs = Date.now() - startTime;
        const responseBody = await response.text();

        // Log delivery
        await db.insert(schema.webhookDeliveries).values({
            id: deliveryId,
            webhookId: webhook.id,
            event,
            payload: JSON.stringify(payload),
            status: response.ok ? "success" : "failure",
            responseCode: response.status,
            responseBody: responseBody.slice(0, 1000), // Truncate
            durationMs,
            requestHeaders: JSON.stringify(headers),
            responseHeaders: JSON.stringify(Object.fromEntries(response.headers.entries())),
        });

        // Update webhook stats
        await db
            .update(schema.webhooks)
            .set({
                deliveryCount: (webhook.deliveryCount || 0) + 1,
                lastDeliveryStatus: response.ok ? "success" : "failure",
                lastDeliveryAt: new Date(),
            })
            .where(eq(schema.webhooks.id, webhook.id));

    } catch (error: any) {
        const durationMs = Date.now() - startTime;

        // Log failure
        await db.insert(schema.webhookDeliveries).values({
            id: deliveryId,
            webhookId: webhook.id,
            event,
            payload: JSON.stringify(payload),
            status: "failure",
            responseCode: 0,
            error: error.message,
            durationMs,
        });

        // Update webhook stats
        await db
            .update(schema.webhooks)
            .set({
                deliveryCount: (webhook.deliveryCount || 0) + 1,
                lastDeliveryStatus: "failure",
                lastDeliveryAt: new Date(),
            })
            .where(eq(schema.webhooks.id, webhook.id));
    }
}

/**
 * Sign payload with secret using HMAC-SHA256
 */
function signPayload(payload: any, secret: string): string {
    if (!secret) return "";
    return crypto
        .createHmac("sha256", secret)
        .update(JSON.stringify(payload))
        .digest("hex");
}