Skip to content

Commit

Permalink
Add endpoints for Twilio's webhooks
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Apr 14, 2024
1 parent 12fdcfb commit 520585c
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 60 deletions.
40 changes: 40 additions & 0 deletions app/api/webhook/twilio/authenticateAndRecordTwilioRequest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
28 changes: 28 additions & 0 deletions app/api/webhook/twilio/inbound/route.ts
Original file line number Diff line number Diff line change
@@ -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';
22 changes: 22 additions & 0 deletions app/api/webhook/twilio/outbound/route.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 9 additions & 0 deletions app/lib/database/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions app/lib/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions app/lib/database/scheme/TwilioWebhookCallsTable.ts
Original file line number Diff line number Diff line change
@@ -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<DBConnection, 'TwilioWebhookCallsTable'> {
webhookCallId = this.autogeneratedPrimaryKey('webhook_call_id', 'int');
webhookCallDate = this.column<ZonedDateTime>('webhook_call_date', 'customLocalDateTime', 'dateTime', TemporalTypeAdapter);
webhookCallEndpoint = this.column<TwilioWebhookEndpoint>('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');
}
}


4 changes: 4 additions & 0 deletions ts-sql.build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
195 changes: 135 additions & 60 deletions ts-sql.scheme.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down Expand Up @@ -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: ""
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 520585c

Please sign in to comment.