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
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Add the WebhookSubscription model (issue #997) and repoint WebhookDelivery
-- at it. WebhookDelivery.subscriptionId previously referenced the billing
-- Subscription model, which was a mistake carried over from an earlier PR —
-- webhook deliveries belong to a webhook subscription, not a tip billing
-- subscription.

-- CreateEnum
CREATE TYPE "WebhookSubscriptionStatus" AS ENUM ('ACTIVE', 'DISABLED');

-- DropForeignKey
ALTER TABLE "WebhookDelivery" DROP CONSTRAINT "WebhookDelivery_subscriptionId_fkey";

-- CreateTable
CREATE TABLE "WebhookSubscription" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[],
"status" "WebhookSubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),

CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "WebhookSubscription_ownerId_idx" ON "WebhookSubscription"("ownerId");

-- AddForeignKey
ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "WebhookSubscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
38 changes: 33 additions & 5 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ model User {
notificationPreference NotificationPreference?
leaderboardSnapshots LeaderboardSnapshot[]
streak Streak?
webhookSubscriptions WebhookSubscription[]

@@index([createdAt])
}
Expand Down Expand Up @@ -328,26 +329,53 @@ model Subscription {
/// Soft-delete marker: non-null means the record is logically deleted.
deletedAt DateTime?

webhookDeliveries WebhookDelivery[]

@@index([tipperId])
@@index([creatorId])
@@index([nextChargeAt])
}

/// Lifecycle status of a webhook subscription.
enum WebhookSubscriptionStatus {
ACTIVE
DISABLED
}

/// A consumer-registered HTTP endpoint that receives signed event payloads
/// (issue #997). The `secret` is generated on creation and used to compute an
/// HMAC-SHA256 signature for every outgoing delivery so consumers can verify
/// payload authenticity.
model WebhookSubscription {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
url String
secret String
/// Event types this subscription receives, e.g. "tip.received".
events String[]
status WebhookSubscriptionStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Soft-delete marker: non-null means the record is logically deleted.
deletedAt DateTime?

deliveries WebhookDelivery[]

@@index([ownerId])
}

/// Delivery state of a single webhook notification attempt.
enum WebhookDeliveryStatus {
PENDING
SUCCESS
FAILED
}

/// A single delivery attempt of a subscription billing event to a webhook
/// consumer (e.g. notifying an integrator that a charge succeeded or failed).
/// A single delivery attempt of an event payload to a webhook subscription's
/// URL (e.g. notifying an integrator that a tip was received).
model WebhookDelivery {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
status WebhookDeliveryStatus @default(PENDING)
responseCode Int?
attempts Int @default(0)
Expand Down
77 changes: 75 additions & 2 deletions backend/src/modules/webhooks/webhooks.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,81 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { BadRequestError } from "../../common/errors/AppError.js";
import { listDeliveries, getDelivery } from "./webhooks.service.js";
import { deliveryQuerySchema, deliveryIdParamSchema } from "./webhooks.schema.js";
import type { AuthPayload } from "../auth/auth.types.js";
import {
listDeliveries,
getDelivery,
createSubscription,
listSubscriptions,
deleteSubscription,
} from "./webhooks.service.js";
import {
deliveryQuerySchema,
deliveryIdParamSchema,
createWebhookSubscriptionSchema,
listWebhookSubscriptionsQuerySchema,
webhookSubscriptionIdParamSchema,
} from "./webhooks.schema.js";

/** POST /webhooks/subscriptions — registers a new webhook subscription. */
export async function createSubscriptionController(
req: Request,
res: Response,
next: NextFunction,
) {
try {
const auth = req.auth as AuthPayload;
const data = createWebhookSubscriptionSchema.parse(req.body);
const result = await createSubscription(auth.userId, data);
res.status(201).json({ data: result });
} catch (error) {
if (error instanceof z.ZodError) {
next(new BadRequestError("Invalid webhook subscription data", error.issues));
} else {
next(error);
}
}
}

/** GET /webhooks/subscriptions — lists the authenticated user's webhook subscriptions. */
export async function listSubscriptionsController(
req: Request,
res: Response,
next: NextFunction,
) {
try {
const auth = req.auth as AuthPayload;
const query = listWebhookSubscriptionsQuerySchema.parse(req.query);
const result = await listSubscriptions(auth.userId, query.page, query.limit);
res.json({ data: result });
} catch (error) {
if (error instanceof z.ZodError) {
next(new BadRequestError("Invalid query parameters", error.issues));
} else {
next(error);
}
}
}

/** DELETE /webhooks/subscriptions/:id — removes a webhook subscription (owner only). */
export async function deleteSubscriptionController(
req: Request,
res: Response,
next: NextFunction,
) {
try {
const auth = req.auth as AuthPayload;
const { id } = webhookSubscriptionIdParamSchema.parse(req.params);
await deleteSubscription(auth.userId, id);
res.status(204).send();
} catch (error) {
if (error instanceof z.ZodError) {
next(new BadRequestError("Invalid webhook subscription ID", error.issues));
} else {
next(error);
}
}
}

export async function listDeliveriesController(
req: Request,
Expand Down
7 changes: 7 additions & 0 deletions backend/src/modules/webhooks/webhooks.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import { requireAuth } from "../auth/auth.middleware.js";
import {
listDeliveriesController,
getDeliveryController,
createSubscriptionController,
listSubscriptionsController,
deleteSubscriptionController,
} from "./webhooks.controller.js";

export const webhooksRouter = Router();

webhooksRouter.post("/subscriptions", requireAuth, createSubscriptionController);
webhooksRouter.get("/subscriptions", requireAuth, listSubscriptionsController);
webhooksRouter.delete("/subscriptions/:id", requireAuth, deleteSubscriptionController);

webhooksRouter.get("/deliveries", requireAuth, listDeliveriesController);
webhooksRouter.get("/deliveries/:id", requireAuth, getDeliveryController);
32 changes: 32 additions & 0 deletions backend/src/modules/webhooks/webhooks.schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import { z } from "zod";

/** Event types a webhook subscription can be registered for. */
export const WEBHOOK_EVENT_TYPES = [
"tip.received",
"tip.sent",
"goal.completed",
"withdrawal.completed",
"credit_score.updated",
] as const;

export const createWebhookSubscriptionSchema = z.object({
url: z
.string()
.url("Must be a valid URL")
.startsWith("https://", "Webhook URL must use https"),
events: z
.array(z.enum(WEBHOOK_EVENT_TYPES))
.min(1, "At least one event is required"),
});

export const listWebhookSubscriptionsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});

export const webhookSubscriptionIdParamSchema = z.object({
id: z.string().min(1, "Webhook subscription ID is required"),
});

export type CreateWebhookSubscriptionInput = z.infer<typeof createWebhookSubscriptionSchema>;
export type ListWebhookSubscriptionsQuery = z.infer<typeof listWebhookSubscriptionsQuerySchema>;
export type WebhookSubscriptionIdParam = z.infer<typeof webhookSubscriptionIdParamSchema>;

export const deliveryQuerySchema = z.object({
subscriptionId: z.string().optional(),
status: z.enum(["PENDING", "SUCCESS", "FAILED"]).optional(),
Expand Down
96 changes: 94 additions & 2 deletions backend/src/modules/webhooks/webhooks.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,99 @@
import { prisma } from "../../db/prisma.js";
import { logger } from "../../common/utils/logger.js";
import { NotFoundError } from "../../common/errors/AppError.js";
import type { WebhookDeliveryResponse, WebhookDeliveryListResponse } from "./webhooks.types.js";
import { ForbiddenError, NotFoundError } from "../../common/errors/AppError.js";
import { generateWebhookSecret } from "./webhooks.signing.js";
import type {
WebhookDeliveryResponse,
WebhookDeliveryListResponse,
WebhookSubscriptionCreateResponse,
WebhookSubscriptionListResponse,
WebhookSubscriptionResponse,
} from "./webhooks.types.js";
import type { CreateWebhookSubscriptionInput } from "./webhooks.schema.js";

type WebhookSubscriptionRow = {
id: string;
ownerId: string;
url: string;
events: string[];
status: string;
createdAt: Date;
updatedAt: Date;
};

function toSubscription(row: WebhookSubscriptionRow): WebhookSubscriptionResponse {
return {
id: row.id,
ownerId: row.ownerId,
url: row.url,
events: row.events,
status: row.status as WebhookSubscriptionResponse["status"],
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}

/** Creates a webhook subscription for `ownerId`. The secret is returned once. */
export async function createSubscription(
ownerId: string,
data: CreateWebhookSubscriptionInput,
): Promise<WebhookSubscriptionCreateResponse> {
logger.info({ ownerId, url: data.url, events: data.events }, "Creating webhook subscription");

const secret = generateWebhookSecret();
const row = await prisma.webhookSubscription.create({
data: {
ownerId,
url: data.url,
events: data.events,
secret,
},
});

return { ...toSubscription(row), secret };
}

/** Lists webhook subscriptions owned by `ownerId`. */
export async function listSubscriptions(
ownerId: string,
page: number,
limit: number,
): Promise<WebhookSubscriptionListResponse> {
logger.info({ ownerId, page, limit }, "Listing webhook subscriptions");

const where = { ownerId, deletedAt: null };
const skip = (page - 1) * limit;

const [rows, total] = await Promise.all([
prisma.webhookSubscription.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: limit,
}),
prisma.webhookSubscription.count({ where }),
]);

return { entries: rows.map(toSubscription), total, page, limit };
}

/** Soft-deletes a webhook subscription. Only the owner may delete it. */
export async function deleteSubscription(ownerId: string, id: string): Promise<void> {
const existing = await prisma.webhookSubscription.findUnique({ where: { id } });
if (!existing || existing.deletedAt) {
throw new NotFoundError(`Webhook subscription ${id} not found`);
}
if (existing.ownerId !== ownerId) {
throw new ForbiddenError("You can only delete your own webhook subscriptions");
}

await prisma.webhookSubscription.update({
where: { id },
data: { deletedAt: new Date(), status: "DISABLED" },
});

logger.info({ ownerId, subscriptionId: id }, "Webhook subscription deleted");
}

export async function listDeliveries(
page: number,
Expand Down
Loading
Loading