Compare commits
54 Commits
main
...
add_bookin
Author | SHA1 | Date |
---|---|---|
kodiakhq[bot] | 6132c87c72 | |
kodiakhq[bot] | 98fe63625a | |
kodiakhq[bot] | 4b0f2e56ef | |
kodiakhq[bot] | 3667ea27d4 | |
kodiakhq[bot] | 5abd634a82 | |
kodiakhq[bot] | 9bc900f8f7 | |
kodiakhq[bot] | 8fa3cb3bed | |
kodiakhq[bot] | 57d87873a1 | |
kodiakhq[bot] | c56b4a618d | |
kodiakhq[bot] | a4df8dc99d | |
kodiakhq[bot] | 24ab2bd843 | |
kodiakhq[bot] | 5579a89613 | |
kodiakhq[bot] | 55847647ff | |
kodiakhq[bot] | 91e2b49c5f | |
kodiakhq[bot] | f53fcce859 | |
kodiakhq[bot] | ed63a315f1 | |
kodiakhq[bot] | eda9cd3d15 | |
kodiakhq[bot] | 926f91d435 | |
kodiakhq[bot] | b000b970b5 | |
kodiakhq[bot] | 047e417a6a | |
kodiakhq[bot] | 9141f0a66d | |
kodiakhq[bot] | 0d0d03d693 | |
kodiakhq[bot] | ce42c572b8 | |
kodiakhq[bot] | 9d9bd3283a | |
kodiakhq[bot] | 42677cfac4 | |
kodiakhq[bot] | cd0ad9c1da | |
kodiakhq[bot] | ff105c72ac | |
kodiakhq[bot] | e5c5ee6ecb | |
kodiakhq[bot] | ada30fd353 | |
kodiakhq[bot] | 565a82649c | |
kodiakhq[bot] | 6d8d2c2ed0 | |
kodiakhq[bot] | 40892067d7 | |
kodiakhq[bot] | c7e47171ac | |
kodiakhq[bot] | fe9f1f56e0 | |
kodiakhq[bot] | e6d034a476 | |
kodiakhq[bot] | d9a46bbd5e | |
kodiakhq[bot] | f1f86d8c32 | |
kodiakhq[bot] | 1e92d908dc | |
kodiakhq[bot] | c72629cffe | |
kodiakhq[bot] | 3059163979 | |
kodiakhq[bot] | 8dc4c7c25c | |
Bill Gale | 41031aedd6 | |
zomars | 31b1e8b044 | |
zomars | cd2b279308 | |
Syed Ali Shahbaz | 410bc2a87a | |
Bill Gale | 0a0715dfbc | |
Peer Richelsen | 82fb4a9dcd | |
Syed Ali Shahbaz | 57dfde4e4d | |
Peer Richelsen | fdd9e99b4d | |
Bill Gale | c02cdacebb | |
Bill Gale | 6b4e04b4fd | |
Bill Gale | 67e0592c66 | |
Bill Gale | 6455bbd4d4 | |
Bill Gale | ff568f4aa4 |
|
@ -5,6 +5,7 @@ import { useMutation } from "react-query";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
|
import showToast from "@lib/notification";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
import TableActions, { ActionType } from "@components/ui/TableActions";
|
||||||
|
@ -27,6 +28,7 @@ function BookingListItem(booking: BookingItem) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new HttpError({ statusCode: res.status });
|
throw new HttpError({ statusCode: res.status });
|
||||||
}
|
}
|
||||||
|
showToast(t("booking_confirmed"), "success");
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSettled() {
|
async onSettled() {
|
||||||
|
|
|
@ -5,4 +5,5 @@ export const WEBHOOK_TRIGGER_EVENTS = [
|
||||||
WebhookTriggerEvents.BOOKING_CANCELLED,
|
WebhookTriggerEvents.BOOKING_CANCELLED,
|
||||||
WebhookTriggerEvents.BOOKING_CREATED,
|
WebhookTriggerEvents.BOOKING_CREATED,
|
||||||
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
WebhookTriggerEvents.BOOKING_RESCHEDULED,
|
||||||
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED"];
|
WebhookTriggerEvents.BOOKING_CONFIRMED,
|
||||||
|
] as ["BOOKING_CANCELLED", "BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CONFIRMED"];
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { CalendarEvent, AdditionInformation } from "@lib/integrations/calendar/i
|
||||||
import logger from "@lib/logger";
|
import logger from "@lib/logger";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { BookingConfirmBody } from "@lib/types/booking";
|
import { BookingConfirmBody } from "@lib/types/booking";
|
||||||
|
import sendPayload from "@lib/webhooks/sendPayload";
|
||||||
|
import getSubscribers from "@lib/webhooks/subscriptions";
|
||||||
|
|
||||||
import { getTranslation } from "@server/lib/i18n";
|
import { getTranslation } from "@server/lib/i18n";
|
||||||
|
|
||||||
|
@ -84,6 +86,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
id: bookingId,
|
id: bookingId,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
userId: true,
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
startTime: true,
|
startTime: true,
|
||||||
|
@ -153,6 +156,23 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
await sendScheduledEmails({ ...evt, additionInformation: metadata });
|
await sendScheduledEmails({ ...evt, additionInformation: metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hook up the webhook logic here
|
||||||
|
const eventTrigger = "BOOKING_CONFIRMED";
|
||||||
|
// Send Webhook call if hooked to BOOKING.CONFIRMED
|
||||||
|
const subscribers = await getSubscribers(booking.userId, eventTrigger);
|
||||||
|
const promises = subscribers.map((sub) =>
|
||||||
|
sendPayload(
|
||||||
|
eventTrigger,
|
||||||
|
new Date().toISOString(),
|
||||||
|
sub.subscriberUrl,
|
||||||
|
evt,
|
||||||
|
sub.payloadTemplate
|
||||||
|
).catch((e) => {
|
||||||
|
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${sub.subscriberUrl}`, e);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.all(promises);
|
||||||
|
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
id: bookingId,
|
id: bookingId,
|
||||||
|
|
|
@ -1,6 +1,35 @@
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { createHttpServer, todo, waitFor } from "./lib/testUtils";
|
import { WEBHOOK_TRIGGER_EVENTS } from "../lib/webhooks/constants";
|
||||||
|
import { createHttpServer, Request, todo } from "./lib/testUtils";
|
||||||
|
|
||||||
|
function removeDynamicProps(body: any) {
|
||||||
|
// remove dynamic properties that differs depending on where you run the tests
|
||||||
|
const dynamic = "[redacted/dynamic]";
|
||||||
|
body.createdAt = dynamic;
|
||||||
|
body.payload.startTime = dynamic;
|
||||||
|
body.payload.endTime = dynamic;
|
||||||
|
body.payload.location = dynamic;
|
||||||
|
for (const attendee of body.payload.attendees) {
|
||||||
|
attendee.timeZone = dynamic;
|
||||||
|
if (attendee.id) attendee.id = dynamic;
|
||||||
|
if (attendee.bookingId) attendee.bookingId = dynamic;
|
||||||
|
}
|
||||||
|
body.payload.organizer.timeZone = dynamic;
|
||||||
|
body.payload.uid = dynamic;
|
||||||
|
body.payload.additionInformation = dynamic;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRequest(requestList: Request[], triggerEvent: typeof WEBHOOK_TRIGGER_EVENTS[number]) {
|
||||||
|
return requestList.find(
|
||||||
|
(r) =>
|
||||||
|
r.body &&
|
||||||
|
typeof r.body === "object" &&
|
||||||
|
"triggerEvent" in r.body &&
|
||||||
|
(r.body as any).triggerEvent === triggerEvent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("integrations", () => {
|
test.describe("integrations", () => {
|
||||||
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||||
|
@ -18,10 +47,19 @@ test.describe("integrations", () => {
|
||||||
todo("Can add CalDav Calendar");
|
todo("Can add CalDav Calendar");
|
||||||
|
|
||||||
todo("Can add Apple Calendar");
|
todo("Can add Apple Calendar");
|
||||||
|
});
|
||||||
|
|
||||||
test("add webhook & test that creating an event triggers a webhook call", async ({ page }, testInfo) => {
|
test.describe("Webhooks", () => {
|
||||||
const webhookReceiver = createHttpServer();
|
test.use({ storageState: "playwright/artifacts/proStorageState.json" });
|
||||||
|
|
||||||
|
let webhookReceiver: ReturnType<typeof createHttpServer>;
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
webhookReceiver = createHttpServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Can add webhooks", async ({ page }) => {
|
||||||
|
await page.goto("/integrations");
|
||||||
// --- add webhook
|
// --- add webhook
|
||||||
await page.click('[data-testid="new_webhook"]');
|
await page.click('[data-testid="new_webhook"]');
|
||||||
expect(page.locator(`[data-testid='WebhookDialogForm']`)).toBeVisible();
|
expect(page.locator(`[data-testid='WebhookDialogForm']`)).toBeVisible();
|
||||||
|
@ -30,11 +68,16 @@ test.describe("integrations", () => {
|
||||||
|
|
||||||
await page.click("[type=submit]");
|
await page.click("[type=submit]");
|
||||||
|
|
||||||
|
await page.waitForSelector(`text=Webhook created successfully!`);
|
||||||
|
|
||||||
// dialog is closed
|
// dialog is closed
|
||||||
expect(page.locator(`[data-testid='WebhookDialogForm']`)).not.toBeVisible();
|
expect(page.locator(`[data-testid='WebhookDialogForm']`)).not.toBeVisible();
|
||||||
// page contains the url
|
// page contains the url
|
||||||
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// test.describe.parallel("Booking events", () => {
|
||||||
|
test("BOOKING_CREATED is triggered", async ({ page }, testInfo) => {
|
||||||
// --- Book the first available day next month in the pro user's "30min"-event
|
// --- Book the first available day next month in the pro user's "30min"-event
|
||||||
await page.goto(`/pro/30min`);
|
await page.goto(`/pro/30min`);
|
||||||
await page.click('[data-testid="incrementMonth"]');
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
@ -46,33 +89,64 @@ test.describe("integrations", () => {
|
||||||
await page.fill('[name="email"]', "test@example.com");
|
await page.fill('[name="email"]', "test@example.com");
|
||||||
await page.press('[name="email"]', "Enter");
|
await page.press('[name="email"]', "Enter");
|
||||||
|
|
||||||
// --- check that webhook was called
|
await page.waitForNavigation({
|
||||||
await waitFor(() => {
|
url(url) {
|
||||||
expect(webhookReceiver.requestList.length).toBe(1);
|
return url.pathname.endsWith("/success");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [request] = webhookReceiver.requestList;
|
// --- check that webhook was called
|
||||||
const body = request.body as any;
|
const request = findRequest(webhookReceiver.requestList, "BOOKING_CREATED");
|
||||||
|
if (!request) throw Error("No request found for 'BOOKING_CREATED'");
|
||||||
// remove dynamic properties that differs depending on where you run the tests
|
|
||||||
const dynamic = "[redacted/dynamic]";
|
|
||||||
body.createdAt = dynamic;
|
|
||||||
body.payload.startTime = dynamic;
|
|
||||||
body.payload.endTime = dynamic;
|
|
||||||
body.payload.location = dynamic;
|
|
||||||
for (const attendee of body.payload.attendees) {
|
|
||||||
attendee.timeZone = dynamic;
|
|
||||||
}
|
|
||||||
body.payload.organizer.timeZone = dynamic;
|
|
||||||
body.payload.uid = dynamic;
|
|
||||||
body.payload.additionInformation = dynamic;
|
|
||||||
|
|
||||||
|
const body = removeDynamicProps(request.body);
|
||||||
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||||
// console.log("BODY", body);
|
// console.log("BODY", body);
|
||||||
// Text files shouldn't have platform specific suffixes
|
// Text files shouldn't have platform specific suffixes
|
||||||
testInfo.snapshotSuffix = "";
|
testInfo.snapshotSuffix = "";
|
||||||
expect(JSON.stringify(body)).toMatchSnapshot(`webhookResponse.txt`);
|
expect(JSON.stringify(body)).toMatchSnapshot(`BOOKING_CREATED-webhook-payload.txt`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("BOOKING_CONFIRMED is triggered", async ({ page }, testInfo) => {
|
||||||
|
// --- Book the first available day next month in the pro user's "30min"-event
|
||||||
|
await page.goto(`/pro/opt-in`);
|
||||||
|
await page.click('[data-testid="incrementMonth"]');
|
||||||
|
await page.click('[data-testid="day"][data-disabled="false"]');
|
||||||
|
await page.click('[data-testid="time"]');
|
||||||
|
|
||||||
|
// --- fill form
|
||||||
|
await page.fill('[name="name"]', "Test Testson");
|
||||||
|
await page.fill('[name="email"]', "test@example.com");
|
||||||
|
await page.press('[name="email"]', "Enter");
|
||||||
|
|
||||||
|
await page.waitForNavigation({
|
||||||
|
url(url) {
|
||||||
|
return url.pathname.endsWith("/success");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to bookings to confirm it
|
||||||
|
await page.goto(`/bookings`);
|
||||||
|
|
||||||
|
// Confirm the booking
|
||||||
|
await page.click('[data-testid="confirm"]');
|
||||||
|
|
||||||
|
// Wait for the success message
|
||||||
|
await page.waitForSelector(`text=Booking Confirmed`);
|
||||||
|
|
||||||
|
const request = findRequest(webhookReceiver.requestList, "BOOKING_CONFIRMED");
|
||||||
|
if (!request) throw Error("No request found for 'BOOKING_CONFIRMED'");
|
||||||
|
|
||||||
|
const body = removeDynamicProps(request.body);
|
||||||
|
// if we change the shape of our webhooks, we can simply update this by clicking `u`
|
||||||
|
// console.log("BODY", body);
|
||||||
|
// Text files shouldn't have platform specific suffixes
|
||||||
|
testInfo.snapshotSuffix = "";
|
||||||
|
expect(JSON.stringify(body)).toMatchSnapshot(`BOOKING_CONFIRMED-webhook-payload.txt`);
|
||||||
|
});
|
||||||
|
// });
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
webhookReceiver.close();
|
webhookReceiver.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
{"triggerEvent":"BOOKING_CONFIRMED","createdAt":"[redacted/dynamic]","payload":{"type":"opt-in between Pro Example and Test Testson","title":"opt-in between Pro Example and Test Testson","description":"","startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"email":"pro@example.com","name":"Pro Example","timeZone":"[redacted/dynamic]"},"attendees":[{"id":"[redacted/dynamic]","email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","bookingId":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","uid":"[redacted/dynamic]","additionInformation":"[redacted/dynamic]"}}
|
|
@ -16,7 +16,7 @@ export function randomString(length = 12) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Request = IncomingMessage & { body?: unknown };
|
export type Request = IncomingMessage & { body?: unknown };
|
||||||
type RequestHandlerOptions = { req: Request; res: ServerResponse };
|
type RequestHandlerOptions = { req: Request; res: ServerResponse };
|
||||||
type RequestHandler = (opts: RequestHandlerOptions) => void;
|
type RequestHandler = (opts: RequestHandlerOptions) => void;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'BOOKING_CONFIRMED';
|
|
@ -315,6 +315,7 @@ enum WebhookTriggerEvents {
|
||||||
BOOKING_CREATED
|
BOOKING_CREATED
|
||||||
BOOKING_RESCHEDULED
|
BOOKING_RESCHEDULED
|
||||||
BOOKING_CANCELLED
|
BOOKING_CANCELLED
|
||||||
|
BOOKING_CONFIRMED
|
||||||
}
|
}
|
||||||
|
|
||||||
model Webhook {
|
model Webhook {
|
||||||
|
|
|
@ -229,6 +229,12 @@ async function main() {
|
||||||
length: 60,
|
length: 60,
|
||||||
price: 50,
|
price: 50,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "opt-in",
|
||||||
|
slug: "opt-in",
|
||||||
|
length: 60,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue