From 520585ca91bcb868c366e16be8e1cdd7e8045273 Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Sun, 14 Apr 2024 21:27:00 +0100 Subject: [PATCH] Add endpoints for Twilio's webhooks --- .../authenticateAndRecordTwilioRequest.ts | 40 ++++ app/api/webhook/twilio/inbound/route.ts | 28 +++ app/api/webhook/twilio/outbound/route.ts | 22 ++ app/lib/database/Types.ts | 9 + app/lib/database/index.ts | 2 + .../scheme/TwilioWebhookCallsTable.ts | 36 ++++ ts-sql.build.mjs | 4 + ts-sql.scheme.yaml | 195 ++++++++++++------ 8 files changed, 276 insertions(+), 60 deletions(-) create mode 100644 app/api/webhook/twilio/authenticateAndRecordTwilioRequest.ts create mode 100644 app/api/webhook/twilio/inbound/route.ts create mode 100644 app/api/webhook/twilio/outbound/route.ts create mode 100644 app/lib/database/scheme/TwilioWebhookCallsTable.ts diff --git a/app/api/webhook/twilio/authenticateAndRecordTwilioRequest.ts b/app/api/webhook/twilio/authenticateAndRecordTwilioRequest.ts new file mode 100644 index 00000000..f7a5fcdd --- /dev/null +++ b/app/api/webhook/twilio/authenticateAndRecordTwilioRequest.ts @@ -0,0 +1,40 @@ +// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. + +import type { NextRequest } from 'next/server'; +import { notFound } from 'next/navigation'; + +import type { TwilioWebhookEndpoint } from '@lib/database/Types'; +import { readSetting } from '@lib/Settings'; +import db, { tTwilioWebhookCalls} from '@lib/database'; + +/** + * Authenticates that the given `request` indeed was issued by Twilio, based on the signature that + * they (hopefully) included as an HTTP header. When the authentication was successful, the request + * will be logged, and the request's body will be returned as a string. + */ +export async function authenticateAndRecordTwilioRequest( + request: NextRequest, endpoint: TwilioWebhookEndpoint) +{ + const authToken = await readSetting('integration-twilio-account-auth-token'); + if (!authToken) + notFound(); // Twilio is not enabled for this instance + + const requestBody = await request.text(); + + const dbInstance = db; + await dbInstance.insertInto(tTwilioWebhookCalls) + .set({ + webhookCallDate: dbInstance.currentZonedDateTime(), + webhookCallEndpoint: endpoint, + webhookRequestSource: request.ip ?? request.headers.get('x-forwarded-for'), + webhookRequestMethod: request.method, + webhookRequestUrl: request.url, + webhookRequestHeaders: JSON.stringify([ ...request.headers.entries() ]), + webhookRequestBody: requestBody, + }) + .executeInsert(); + + // TODO: Actually authenticate and validate the `requestBody`. + return requestBody; +} diff --git a/app/api/webhook/twilio/inbound/route.ts b/app/api/webhook/twilio/inbound/route.ts new file mode 100644 index 00000000..457122d5 --- /dev/null +++ b/app/api/webhook/twilio/inbound/route.ts @@ -0,0 +1,28 @@ +// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. + +import type { NextRequest } from 'next/server'; + +import twilio from 'twilio'; + +import { TwilioWebhookEndpoint } from '@lib/database/Types'; +import { authenticateAndRecordTwilioRequest } from '../authenticateAndRecordTwilioRequest'; + +/** + * Webhook invoked when an inbound message has been received over one of our communication channels, + * generally either SMS or WhatsApp. An immediate response is expected. + */ +export async function GET(request: NextRequest) { + const body = await authenticateAndRecordTwilioRequest(request, TwilioWebhookEndpoint.Inbound); + // TODO: Do something with the `body` + + const response = new twilio.twiml.MessagingResponse(); + return new Response(response.toString(), { + headers: [ + [ 'Content-Type', 'text/xml' ], + ], + status: /* HTTP OK= */ 200, + }); +} + +export const dynamic = 'force-dynamic'; diff --git a/app/api/webhook/twilio/outbound/route.ts b/app/api/webhook/twilio/outbound/route.ts new file mode 100644 index 00000000..b2c10627 --- /dev/null +++ b/app/api/webhook/twilio/outbound/route.ts @@ -0,0 +1,22 @@ +// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved. +// Use of this source code is governed by a MIT license that can be found in the LICENSE file. + +import type { NextRequest } from 'next/server'; + +import { TwilioWebhookEndpoint } from '@lib/database/Types'; +import { authenticateAndRecordTwilioRequest } from '../authenticateAndRecordTwilioRequest'; + +/** + * Webhook invoked when the status of an outbound message has been updated. This may happen minutes, + * sometimes even hours after sending the message, so we need to keep our stored state in sync. + */ +export async function POST(request: NextRequest) { + const body = await authenticateAndRecordTwilioRequest(request, TwilioWebhookEndpoint.Outbound); + // TODO: Do something with the `body` + + return new Response(undefined, { + status: /* HTTP OK= */ 200, + }); +} + +export const dynamic = 'force-dynamic'; diff --git a/app/lib/database/Types.ts b/app/lib/database/Types.ts index 35039bf1..04d79a7f 100644 --- a/app/lib/database/Types.ts +++ b/app/lib/database/Types.ts @@ -189,6 +189,15 @@ export enum TaskResult { UnknownFailure = 'UnknownFailure', } +/** + * Webhook endpoint that was posted to from the Twilio infrastructure. + * @see Table `twilio_webhook_calls` + */ +export enum TwilioWebhookEndpoint { + Inbound = 'Inbound', + Outbound = 'Outbound', +} + /** * Gender of a vendor, simplified display. * @see Table `vendors` diff --git a/app/lib/database/index.ts b/app/lib/database/index.ts index 2670596d..ad14dea7 100644 --- a/app/lib/database/index.ts +++ b/app/lib/database/index.ts @@ -40,6 +40,7 @@ import { TeamsTable } from './scheme/TeamsTable'; import { TrainingsTable } from './scheme/TrainingsTable'; import { TrainingsAssignmentsTable } from './scheme/TrainingsAssignmentsTable'; import { TrainingsExtraTable } from './scheme/TrainingsExtraTable'; +import { TwilioWebhookCallsTable } from './scheme/TwilioWebhookCallsTable'; import { UsersAuthTable } from './scheme/UsersAuthTable'; import { UsersEventsTable } from './scheme/UsersEventsTable'; import { UsersPasskeysTable } from './scheme/UsersPasskeysTable'; @@ -89,6 +90,7 @@ export const tTeams = new TeamsTable; export const tTrainings = new TrainingsTable; export const tTrainingsAssignments = new TrainingsAssignmentsTable; export const tTrainingsExtra = new TrainingsExtraTable; +export const tTwilioWebhookCalls = new TwilioWebhookCallsTable; export const tUsersAuth = new UsersAuthTable; export const tUsersEvents = new UsersEventsTable; export const tUsersPasskeys = new UsersPasskeysTable; diff --git a/app/lib/database/scheme/TwilioWebhookCallsTable.ts b/app/lib/database/scheme/TwilioWebhookCallsTable.ts new file mode 100644 index 00000000..c19bb79f --- /dev/null +++ b/app/lib/database/scheme/TwilioWebhookCallsTable.ts @@ -0,0 +1,36 @@ +// @ts-nocheck +/* eslint-disable quotes, max-len */ +/** + * DO NOT EDIT: + * + * This file has been auto-generated from database schema using ts-sql-codegen. + * Any changes will be overwritten. + */ +import { Table } from "ts-sql-query/Table"; +import type { DBConnection } from "../Connection"; +import { + TemporalTypeAdapter, +} from "../TemporalTypeAdapter"; +import { + ZonedDateTime, +} from "../../Temporal"; +import { + TwilioWebhookEndpoint, +} from "../Types"; + +export class TwilioWebhookCallsTable extends Table { + webhookCallId = this.autogeneratedPrimaryKey('webhook_call_id', 'int'); + webhookCallDate = this.column('webhook_call_date', 'customLocalDateTime', 'dateTime', TemporalTypeAdapter); + webhookCallEndpoint = this.column('webhook_call_endpoint', 'enum', 'TwilioWebhookEndpoint'); + webhookRequestSource = this.optionalColumnWithDefaultValue('webhook_request_source', 'string'); + webhookRequestMethod = this.column('webhook_request_method', 'string'); + webhookRequestUrl = this.column('webhook_request_url', 'string'); + webhookRequestHeaders = this.column('webhook_request_headers', 'string'); + webhookRequestBody = this.column('webhook_request_body', 'string'); + + constructor() { + super('twilio_webhook_calls'); + } +} + + diff --git a/ts-sql.build.mjs b/ts-sql.build.mjs index 09898226..47922127 100644 --- a/ts-sql.build.mjs +++ b/ts-sql.build.mjs @@ -230,6 +230,10 @@ do { type: 'SubscriptionType' }, { field: [ 'tasks', 'task_invocation_result' ], type: 'TaskResult' }, + { + field: [ 'twilio_webhook_calls', 'webhook_call_endpoint' ], + type: 'TwilioWebhookEndpoint', + }, { field: [ 'users_auth', 'auth_type' ], type: 'AuthType' }, { field: [ 'users_events', 'registration_status' ], type: 'RegistrationStatus' }, { field: [ 'users_events', 'shirt_fit' ], type: 'ShirtFit' }, diff --git a/ts-sql.scheme.yaml b/ts-sql.scheme.yaml index bea737d4..6154f221 100644 --- a/ts-sql.scheme.yaml +++ b/ts-sql.scheme.yaml @@ -521,6 +521,81 @@ tables: `outbox_result_error_message` text DEFAULT NULL, PRIMARY KEY (`outbox_twilio_id`) ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +- name: twilio_webhook_calls + type: BASE TABLE + comment: "" + columns: + - name: webhook_call_id + type: int(8) unsigned + nullable: false + default: null + extraDef: auto_increment + comment: "" + - name: webhook_call_date + type: datetime + nullable: false + default: null + comment: "" + - name: webhook_call_endpoint + type: enum('Inbound','Outbound') + nullable: false + default: null + comment: "" + - name: webhook_request_source + type: varchar(64) + nullable: true + default: "NULL" + comment: "" + - name: webhook_request_method + type: varchar(32) + nullable: false + default: null + comment: "" + - name: webhook_request_url + type: text + nullable: false + default: null + comment: "" + - name: webhook_request_headers + type: text + nullable: false + default: null + comment: "" + - name: webhook_request_body + type: text + nullable: false + default: null + comment: "" + indexes: + - name: PRIMARY + def: PRIMARY KEY (webhook_call_id) USING BTREE + table: twilio_webhook_calls + columns: + - webhook_call_id + comment: "" + constraints: + - name: PRIMARY + type: PRIMARY KEY + def: PRIMARY KEY (webhook_call_id) + table: twilio_webhook_calls + referencedTable: null + columns: + - webhook_call_id + referencedColumns: [] + comment: "" + triggers: [] + def: |- + CREATE TABLE `twilio_webhook_calls` ( + `webhook_call_id` int(8) unsigned NOT NULL AUTO_INCREMENT, + `webhook_call_date` datetime NOT NULL, + `webhook_call_endpoint` enum('Inbound','Outbound') NOT NULL, + `webhook_request_source` varchar(64) DEFAULT NULL, + `webhook_request_method` varchar(32) NOT NULL, + `webhook_request_url` text NOT NULL, + `webhook_request_headers` text NOT NULL, + `webhook_request_body` text NOT NULL, + PRIMARY KEY (`webhook_call_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - name: displays type: BASE TABLE comment: "" @@ -3296,66 +3371,6 @@ tables: `area_deleted` datetime DEFAULT NULL, PRIMARY KEY (`area_id`,`area_festival_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -- name: events_sales - type: BASE TABLE - comment: "" - columns: - - name: event_id - type: int(8) unsigned - nullable: false - default: null - comment: "" - - name: event_sale_date - type: date - nullable: false - default: null - comment: "" - - name: event_sale_type - type: varchar(32) - nullable: false - default: null - comment: "" - - name: event_sale_count - type: int(8) unsigned - nullable: false - default: null - comment: "" - - name: event_sale_updated - type: datetime - nullable: false - default: null - comment: "" - indexes: - - name: event_id - def: UNIQUE KEY event_id (event_id, event_sale_date, event_sale_type) USING BTREE - table: events_sales - columns: - - event_id - - event_sale_date - - event_sale_type - comment: "" - constraints: - - name: event_id - type: UNIQUE - def: UNIQUE KEY event_id (event_id, event_sale_date, event_sale_type) - table: events_sales - referencedTable: null - columns: - - event_id - - event_sale_date - - event_sale_type - referencedColumns: [] - comment: "" - triggers: [] - def: |- - CREATE TABLE `events_sales` ( - `event_id` int(8) unsigned NOT NULL, - `event_sale_date` date NOT NULL, - `event_sale_type` varchar(32) NOT NULL, - `event_sale_count` int(8) unsigned NOT NULL, - `event_sale_updated` datetime NOT NULL, - UNIQUE KEY `event_id` (`event_id`,`event_sale_date`,`event_sale_type`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - name: events type: BASE TABLE comment: "" @@ -4690,6 +4705,66 @@ tables: `publication_created` datetime NOT NULL, PRIMARY KEY (`publication_id`) ) ENGINE=InnoDB AUTO_INCREMENT=[Redacted by tbls] DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci +- name: events_sales + type: BASE TABLE + comment: "" + columns: + - name: event_id + type: int(8) unsigned + nullable: false + default: null + comment: "" + - name: event_sale_date + type: date + nullable: false + default: null + comment: "" + - name: event_sale_type + type: varchar(64) + nullable: false + default: null + comment: "" + - name: event_sale_count + type: int(8) unsigned + nullable: false + default: null + comment: "" + - name: event_sale_updated + type: datetime + nullable: false + default: null + comment: "" + indexes: + - name: event_id + def: UNIQUE KEY event_id (event_id, event_sale_date, event_sale_type) USING BTREE + table: events_sales + columns: + - event_id + - event_sale_date + - event_sale_type + comment: "" + constraints: + - name: event_id + type: UNIQUE + def: UNIQUE KEY event_id (event_id, event_sale_date, event_sale_type) + table: events_sales + referencedTable: null + columns: + - event_id + - event_sale_date + - event_sale_type + referencedColumns: [] + comment: "" + triggers: [] + def: |- + CREATE TABLE `events_sales` ( + `event_id` int(8) unsigned NOT NULL, + `event_sale_date` date NOT NULL, + `event_sale_type` varchar(64) NOT NULL, + `event_sale_count` int(8) unsigned NOT NULL, + `event_sale_updated` datetime NOT NULL, + UNIQUE KEY `event_id` (`event_id`,`event_sale_date`,`event_sale_type`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci relations: - table: events_teams columns: