From cd31316c868be13b8210a5659dc2b2afba212a46 Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 21 Dec 2024 14:55:06 +0300 Subject: [PATCH 1/5] feat(api): webhooks implementation v1 --- api/package.json | 1 + api/pnpm-lock.yaml | 35 +++ api/src/app.module.ts | 4 + .../schemas/webhook-notification.schema.ts | 41 +++ .../schemas/webhook-subscription.schema.ts | 43 +++ api/src/webhook/webhook-event.enum.ts | 3 + api/src/webhook/webhook.controller.ts | 68 +++++ api/src/webhook/webhook.dto.ts | 14 + api/src/webhook/webhook.module.ts | 35 +++ api/src/webhook/webhook.service.ts | 251 ++++++++++++++++++ 10 files changed, 495 insertions(+) create mode 100644 api/src/webhook/schemas/webhook-notification.schema.ts create mode 100644 api/src/webhook/schemas/webhook-subscription.schema.ts create mode 100644 api/src/webhook/webhook-event.enum.ts create mode 100644 api/src/webhook/webhook.controller.ts create mode 100644 api/src/webhook/webhook.dto.ts create mode 100644 api/src/webhook/webhook.module.ts create mode 100644 api/src/webhook/webhook.service.ts diff --git a/api/package.json b/api/package.json index 7850e92..463fe3d 100644 --- a/api/package.json +++ b/api/package.json @@ -27,6 +27,7 @@ "@nestjs/mongoose": "^10.0.10", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.5", + "@nestjs/schedule": "^4.1.1", "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.2.1", "axios": "^1.7.7", diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 5f7b043..f50b029 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@nestjs/platform-express': specifier: ^10.4.5 version: 10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5) + '@nestjs/schedule': + specifier: ^4.1.1 + version: 4.1.1(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1)) '@nestjs/swagger': specifier: ^7.4.2 version: 7.4.2(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2) @@ -806,6 +809,12 @@ packages: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 + '@nestjs/schedule@4.1.1': + resolution: {integrity: sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/schematics@10.2.2': resolution: {integrity: sha512-D4pJ46E8llCA7WPr3cV6sfRqDlvnTjQWnF1fLyKYD3Ldl+KPtlLyIcxaqlLTB0YR9ItKNKIZTJzUehRxR7UUsQ==} peerDependencies: @@ -1178,6 +1187,9 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -1807,6 +1819,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@3.1.7: + resolution: {integrity: sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -2985,6 +3000,10 @@ packages: lru-memoizer@2.3.0: resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + luxon@3.4.4: + resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==} + engines: {node: '>=12'} + magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} @@ -5373,6 +5392,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@4.1.1(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + dependencies: + '@nestjs/common': 10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.5(@nestjs/common@10.4.5(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.5)(reflect-metadata@0.2.2)(rxjs@7.8.1) + cron: 3.1.7 + uuid: 10.0.0 + '@nestjs/schematics@10.2.2(chokidar@3.6.0)(typescript@5.3.3)': dependencies: '@angular-devkit/core': 17.3.10(chokidar@3.6.0) @@ -5927,6 +5953,8 @@ snapshots: '@types/long@4.0.2': optional: true + '@types/luxon@3.4.2': {} + '@types/methods@1.1.4': {} '@types/mime@1.3.5': {} @@ -6691,6 +6719,11 @@ snapshots: create-require@1.1.1: {} + cron@3.1.7: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.4.4 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -8219,6 +8252,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 6.0.0 + luxon@3.4.4: {} + magic-string@0.30.8: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 diff --git a/api/src/app.module.ts b/api/src/app.module.ts index ffa951e..fce66fe 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -5,7 +5,9 @@ import { AuthModule } from './auth/auth.module' import { UsersModule } from './users/users.module' import { ThrottlerModule } from '@nestjs/throttler' import { APP_GUARD } from '@nestjs/core/constants' +import { WebhookModule } from './webhook/webhook.module' import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard' +import { ScheduleModule } from '@nestjs/schedule' @Module({ imports: [ @@ -16,9 +18,11 @@ import { ThrottlerByIpGuard } from './auth/guards/throttle-by-ip.guard' limit: 30, }, ]), + ScheduleModule.forRoot(), AuthModule, UsersModule, GatewayModule, + WebhookModule, ], controllers: [], providers: [ diff --git a/api/src/webhook/schemas/webhook-notification.schema.ts b/api/src/webhook/schemas/webhook-notification.schema.ts new file mode 100644 index 0000000..3e366dc --- /dev/null +++ b/api/src/webhook/schemas/webhook-notification.schema.ts @@ -0,0 +1,41 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' +import { WebhookSubscription } from './webhook-subscription.schema' +import { SMS } from 'src/gateway/schemas/sms.schema' + +export type WebhookNotificationDocument = WebhookNotification & Document + +@Schema({ timestamps: true }) +export class WebhookNotification { + _id?: Types.ObjectId + + @Prop({ type: Types.ObjectId, ref: WebhookSubscription.name, required: true }) + webhookSubscription: WebhookSubscription + + @Prop({ type: String, required: true }) + event: string + + @Prop({ type: Object, required: true }) + payload: object + + @Prop({ type: Types.ObjectId, ref: SMS.name }) + sms: SMS + + @Prop({ type: String }) + deliveredAt: Date + + @Prop({ type: Date }) + lastDeliveryAttemptAt: Date + + @Prop({ type: Date }) + nextDeliveryAttemptAt: Date + + @Prop({ type: Number, default: 0 }) + deliveryAttemptCount: number + + @Prop({ type: Date }) + deliveryAttemptAbortedAt: Date +} + +export const WebhookNotificationSchema = + SchemaFactory.createForClass(WebhookNotification) diff --git a/api/src/webhook/schemas/webhook-subscription.schema.ts b/api/src/webhook/schemas/webhook-subscription.schema.ts new file mode 100644 index 0000000..3e69d04 --- /dev/null +++ b/api/src/webhook/schemas/webhook-subscription.schema.ts @@ -0,0 +1,43 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document, Types } from 'mongoose' +import { User } from 'src/users/schemas/user.schema' +import { WebhookEvent } from '../webhook-event.enum' + +export type WebhookSubscriptionDocument = WebhookSubscription & Document + +@Schema({ timestamps: true }) +export class WebhookSubscription { + _id?: Types.ObjectId + + @Prop({ type: Types.ObjectId, ref: User.name }) + user: User + + @Prop({ type: Boolean, default: true }) + isActive: boolean + + @Prop({ type: [String], default: [WebhookEvent.MESSAGE_RECEIVED] }) + events: string[] + + @Prop({ type: String, required: true }) + deliveryUrl: string + + @Prop({ type: String, required: true }) + signingSecret: string + + @Prop({ type: Number, default: 0 }) + successfulDeliveryCount: number + + @Prop({ type: Number, default: 0 }) + deliveryAttemptCount: number + + @Prop({ type: Date }) + lastDeliveryAttemptAt: Date + + @Prop({ type: Date }) + lastDeliverySuccessAt: Date +} + +export const WebhookSubscriptionSchema = + SchemaFactory.createForClass(WebhookSubscription) + +WebhookSubscriptionSchema.index({ user: 1, events: 1 }, { unique: true }) diff --git a/api/src/webhook/webhook-event.enum.ts b/api/src/webhook/webhook-event.enum.ts new file mode 100644 index 0000000..b3b1ecd --- /dev/null +++ b/api/src/webhook/webhook-event.enum.ts @@ -0,0 +1,3 @@ +export enum WebhookEvent { + MESSAGE_RECEIVED = 'MESSAGE_RECEIVED', +} diff --git a/api/src/webhook/webhook.controller.ts b/api/src/webhook/webhook.controller.ts new file mode 100644 index 0000000..2539e57 --- /dev/null +++ b/api/src/webhook/webhook.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Request, + Param, + Post, + Patch, + Controller, + Get, + UseGuards, +} from '@nestjs/common' +import { WebhookService } from './webhook.service' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' +import { CreateWebhookDto, UpdateWebhookDto } from './webhook.dto' +import { AuthGuard } from 'src/auth/guards/auth.guard' + +@ApiTags('webhooks') +@ApiBearerAuth() +@Controller('webhooks') +export class WebhookController { + constructor(private readonly webhookService: WebhookService) {} + + @Get() + @UseGuards(AuthGuard) + async getWebhooks(@Request() req) { + const data = await this.webhookService.findWebhooksForUser({ + user: req.user, + }) + return { data } + } + + @Get(':webhookId') + @UseGuards(AuthGuard) + async getWebhook(@Request() req, @Param('webhookId') webhookId: string) { + const data = await this.webhookService.findOne({ + user: req.user, + webhookId, + }) + return { data } + } + + @Post() + @UseGuards(AuthGuard) + async createWebhook( + @Request() req, + @Body() createWebhookDto: CreateWebhookDto, + ) { + const data = await this.webhookService.create({ + user: req.user, + createWebhookDto, + }) + return { data } + } + + @Patch(':webhookId') + @UseGuards(AuthGuard) + async updateWebhook( + @Request() req, + @Param('webhookId') webhookId: string, + @Body() updateWebhookDto: UpdateWebhookDto, + ) { + const data = await this.webhookService.update({ + user: req.user, + webhookId, + updateWebhookDto, + }) + return { data } + } +} diff --git a/api/src/webhook/webhook.dto.ts b/api/src/webhook/webhook.dto.ts new file mode 100644 index 0000000..e71f9ad --- /dev/null +++ b/api/src/webhook/webhook.dto.ts @@ -0,0 +1,14 @@ +import { WebhookEvent } from './webhook-event.enum' + +export class CreateWebhookDto { + deliveryUrl: string + signingSecret?: string + events: WebhookEvent[] +} + +export class UpdateWebhookDto { + isActive: boolean + deliveryUrl: string + signingSecret: string + events: WebhookEvent[] +} diff --git a/api/src/webhook/webhook.module.ts b/api/src/webhook/webhook.module.ts new file mode 100644 index 0000000..ad7ca92 --- /dev/null +++ b/api/src/webhook/webhook.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { WebhookController } from './webhook.controller' +import { WebhookService } from './webhook.service' +import { + WebhookSubscription, + WebhookSubscriptionSchema, +} from './schemas/webhook-subscription.schema' +import { + WebhookNotification, + WebhookNotificationSchema, +} from './schemas/webhook-notification.schema' +import { AuthModule } from 'src/auth/auth.module' +import { UsersModule } from 'src/users/users.module' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: WebhookSubscription.name, + schema: WebhookSubscriptionSchema, + }, + { + name: WebhookNotification.name, + schema: WebhookNotificationSchema, + }, + ]), + AuthModule, + UsersModule, + ], + controllers: [WebhookController], + providers: [WebhookService], + exports: [MongooseModule, WebhookService], +}) +export class WebhookModule {} diff --git a/api/src/webhook/webhook.service.ts b/api/src/webhook/webhook.service.ts new file mode 100644 index 0000000..2fae90b --- /dev/null +++ b/api/src/webhook/webhook.service.ts @@ -0,0 +1,251 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common' +import { Model } from 'mongoose' +import { + WebhookSubscription, + WebhookSubscriptionDocument, +} from './schemas/webhook-subscription.schema' +import { InjectModel } from '@nestjs/mongoose' +import { WebhookEvent } from './webhook-event.enum' +import { + WebhookNotification, + WebhookNotificationDocument, +} from './schemas/webhook-notification.schema' +import axios from 'axios' +import { v4 as uuidv4 } from 'uuid' +import { Cron } from '@nestjs/schedule' +import { CronExpression } from '@nestjs/schedule' +import * as crypto from 'crypto' + +@Injectable() +export class WebhookService { + constructor( + @InjectModel(WebhookSubscription.name) + private webhookSubscriptionModel: Model, + @InjectModel(WebhookNotification.name) + private webhookNotificationModel: Model, + ) {} + + async findOne({ user, webhookId }) { + const webhook = await this.webhookSubscriptionModel.findOne({ + _id: webhookId, + user: user._id, + }) + + if (!webhook) { + throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND) + } + return webhook + } + + async findWebhooksForUser({ user }) { + return await this.webhookSubscriptionModel.find({ user: user._id }) + } + + async create({ user, createWebhookDto }) { + const { events, deliveryUrl } = createWebhookDto + + // Add URL validation + try { + new URL(deliveryUrl) + } catch (e) { + throw new HttpException('Invalid delivery URL', HttpStatus.BAD_REQUEST) + } + + const existingSubscription = await this.webhookSubscriptionModel.findOne({ + user: user._id, + events, + }) + + if (existingSubscription) { + throw new HttpException( + 'You have already subscribed to this event', + HttpStatus.BAD_REQUEST, + ) + } + + if (!events.every((event) => Object.values(WebhookEvent).includes(event))) { + throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST) + } + + const signingSecret = uuidv4() + + // TODO: Encrypt signing secret + // const webhookSignatureKey = process.env.WEBHOOK_SIGNATURE_KEY + // const encryptedSigningSecret = encrypt(signingSecret, webhookSignatureKey) + + const webhookSubscription = await this.webhookSubscriptionModel.create({ + user: user._id, + events, + deliveryUrl, + signingSecret, + }) + + return webhookSubscription + } + + async update({ user, webhookId, updateWebhookDto }) { + const webhookSubscription = await this.webhookSubscriptionModel.findOne({ + _id: webhookId, + user: user._id, + }) + + if (!webhookSubscription) { + throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND) + } + + if (updateWebhookDto.hasOwnProperty('isActive')) { + webhookSubscription.isActive = updateWebhookDto.isActive + } + + if (updateWebhookDto.hasOwnProperty('deliveryUrl')) { + webhookSubscription.deliveryUrl = updateWebhookDto.deliveryUrl + } + + // if there is a valid uuid signing secret, update it + if ( + updateWebhookDto.hasOwnProperty('signingSecret') && + updateWebhookDto.signingSecret.length < 20 + ) { + throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST) + } else if (updateWebhookDto.hasOwnProperty('signingSecret')) { + webhookSubscription.signingSecret = updateWebhookDto.signingSecret + } + + await webhookSubscription.save() + + return webhookSubscription + } + + async deliverNotification({ sms, user, event }) { + console.log('deliverNotification') + console.log(sms) + console.log(user) + console.log(event) + const webhookSubscription = await this.webhookSubscriptionModel.findOne({ + user: user._id, + events: { $in: [event] }, + }) + + if (!webhookSubscription || !webhookSubscription.isActive) { + return + } + + if (event === WebhookEvent.MESSAGE_RECEIVED) { + const payload = { + smsId: sms._id, + sender: sms.sender, + message: sms.message, + receivedAt: sms.receivedAt, + deviceId: sms.device, + webhookSubscriptionId: webhookSubscription._id, + webhookEvent: event, + } + const webhookNotification = await this.webhookNotificationModel.create({ + webhookSubscription: webhookSubscription._id, + event, + payload, + sms, + }) + + await this.attemptWebhookDelivery(webhookNotification) + } else { + throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST) + } + } + + private async attemptWebhookDelivery( + webhookNotification: WebhookNotificationDocument, + ) { + const now = new Date() + + const webhookSubscription = await this.webhookSubscriptionModel.findById( + webhookNotification.webhookSubscription, + ) + const deliveryUrl = webhookSubscription?.deliveryUrl + const signingSecret = webhookSubscription?.signingSecret + + const signature = crypto + .createHmac('sha256', signingSecret) + .update(JSON.stringify(webhookNotification.payload)) + .digest('hex') + + try { + await axios.post(deliveryUrl, webhookNotification.payload, { + headers: { + 'X-Signature': signature, + }, + timeout: 10000, + }) + webhookNotification.deliveryAttemptCount += 1 + webhookNotification.lastDeliveryAttemptAt = now + webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt( + webhookNotification.deliveryAttemptCount, + ) + webhookNotification.deliveredAt = now + await webhookNotification.save() + + + webhookSubscription.successfulDeliveryCount += 1 + webhookSubscription.lastDeliverySuccessAt = now + } catch (e) { + console.error( + `Failed to deliver webhook notification ${webhookNotification._id}: received response status code ${e.response.status}`, + ) + webhookNotification.deliveryAttemptCount += 1 + webhookNotification.lastDeliveryAttemptAt = now + webhookNotification.nextDeliveryAttemptAt = this.getNextDeliveryAttemptAt( + webhookNotification.deliveryAttemptCount, + ) + await webhookNotification.save() + + } finally { + webhookSubscription.deliveryAttemptCount += 1 + await webhookSubscription.save() + } + } + + private getNextDeliveryAttemptAt(deliveryAttemptCount: number): Date { + // Delays in minutes + const delaySequence = [ + 1, // 1 minute + 5, // 5 minutes + 30, // 30 minutes + 60, // 1 hour + 360, // 6 hours + 1440, // 1 day + 4320, // 3 days + 10080, // 7 days + 43200, // 30 days + ] + + // Get the delay in minutes (use last value if attempt count exceeds sequence length) + const delayInMinutes = + delaySequence[ + Math.min(deliveryAttemptCount - 1, delaySequence.length - 1) + ] || delaySequence[delaySequence.length - 1] + + // Convert minutes to milliseconds and add to current time + return new Date(Date.now() + delayInMinutes * 60 * 1000) + } + + // Check for notifications that need to be delivered every minute + @Cron(CronExpression.EVERY_MINUTE) + async checkForNotificationsToDeliver() { + const now = new Date() + const notifications = await this.webhookNotificationModel + .find({ + nextDeliveryAttemptAt: { $lte: now }, + deliveredAt: null, + deliveryAttemptCount: { $lt: 10 }, + deliveryAttemptAbortedAt: null, + }) + .sort({ nextDeliveryAttemptAt: 1 }) + .limit(50) + + console.log(`delivering ${notifications.length} webhook notifications`) + + for (const notification of notifications) { + await this.attemptWebhookDelivery(notification) + } + } +} From f04fc47a6616f61ffc834bfbadc851246b35fd53 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 22 Dec 2024 01:02:12 +0300 Subject: [PATCH 2/5] feat(web): build webhook ui --- .../dashboard/(components)/main-dashboard.tsx | 25 +- .../webhooks/create-webhook-dialog.tsx | 198 +++++++++ .../webhooks/delete-webhook-button.tsx | 50 +++ .../webhooks/edit-webhook-dialog.tsx | 201 +++++++++ .../(components)/webhooks/webhook-card.tsx | 139 ++++++ .../(components)/webhooks/webhook-docs.tsx | 169 +++++++ .../webhooks/webhooks-section.tsx | 178 ++++++++ web/components/shared/copy-button.tsx | 49 +++ web/components/ui/alert-dialog.tsx | 141 ++++++ web/components/ui/code.tsx | 17 + web/components/ui/skeleton.tsx | 15 + web/components/ui/tooltip.tsx | 32 ++ web/config/api.ts | 3 + web/lib/constants.ts | 3 + web/lib/types.ts | 17 + web/package.json | 3 + web/pnpm-lock.yaml | 413 ++++++++++++++++++ 17 files changed, 1631 insertions(+), 22 deletions(-) create mode 100644 web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx create mode 100644 web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx create mode 100644 web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx create mode 100644 web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx create mode 100644 web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx create mode 100644 web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx create mode 100644 web/components/shared/copy-button.tsx create mode 100644 web/components/ui/alert-dialog.tsx create mode 100644 web/components/ui/code.tsx create mode 100644 web/components/ui/skeleton.tsx create mode 100644 web/components/ui/tooltip.tsx create mode 100644 web/lib/constants.ts create mode 100644 web/lib/types.ts diff --git a/web/app/(app)/dashboard/(components)/main-dashboard.tsx b/web/app/(app)/dashboard/(components)/main-dashboard.tsx index c54124e..c4486a8 100644 --- a/web/app/(app)/dashboard/(components)/main-dashboard.tsx +++ b/web/app/(app)/dashboard/(components)/main-dashboard.tsx @@ -1,23 +1,16 @@ 'use client' -import { useRouter, usePathname } from 'next/navigation' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Alert, AlertDescription } from '@/components/ui/alert' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -// import Overview from "@/components/overview"; -// import DeviceList from "@/components/device-list"; -// import ApiKeys from "@/components/api-keys"; -// import MessagingPanel from "@/components/messaging-panel"; + import { Webhook, MessageSquare } from 'lucide-react' import { useState } from 'react' import Overview from './overview' import DeviceList from './device-list' import ApiKeys from './api-keys' import Messaging from './messaging' +import WebhooksSection from './webhooks/webhooks-section' export default function DashboardOverview() { - const router = useRouter() - const pathname = usePathname() const [currentTab, setCurrentTab] = useState('overview') @@ -49,19 +42,7 @@ export default function DashboardOverview() { - - - Webhooks (Coming Soon) - - - - - Webhook support is coming soon! You'll be able to configure - endpoints to receive SMS notifications in real-time. - - - - + diff --git a/web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx b/web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx new file mode 100644 index 0000000..80e1001 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/create-webhook-dialog.tsx @@ -0,0 +1,198 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import * as z from 'zod' +import { v4 as uuidv4 } from 'uuid' +import { WebhookData } from '@/lib/types' +import { WEBHOOK_EVENTS } from '@/lib/constants' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { useToast } from '@/hooks/use-toast' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +const formSchema = z.object({ + deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }), + events: z.array(z.string()).min(1, { message: 'Select at least one event' }), + isActive: z.boolean().default(true), + signingSecret: z.string().min(1, { message: 'Signing secret is required' }), +}) + +interface CreateWebhookDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CreateWebhookDialog({ + open, + onOpenChange, +}: CreateWebhookDialogProps) { + const { toast } = useToast() + const queryClient = useQueryClient() + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + deliveryUrl: '', + events: [WEBHOOK_EVENTS.MESSAGE_RECEIVED], + isActive: true, + signingSecret: uuidv4(), + }, + }) + + const createWebhookMutation = useMutation({ + mutationFn: (values: z.infer) => + httpBrowserClient.post(ApiEndpoints.gateway.createWebhook(), values), + onSuccess: () => { + toast({ + title: 'Success', + description: 'Webhook created successfully', + }) + queryClient.invalidateQueries({ queryKey: ['webhooks'] }) + onOpenChange(false) + form.reset() + }, + onError: () => { + toast({ + title: 'Error', + description: 'Failed to create webhook', + variant: 'destructive', + }) + }, + }) + + const onSubmit = (values: z.infer) => { + createWebhookMutation.mutate(values) + } + + return ( + + + + Create Webhook + + Configure your webhook endpoint to receive real-time SMS + notifications. + + +
+ + ( + + Delivery URL + + + + + The URL where webhook notifications will be sent via POST + requests + + + + )} + /> + ( + + Signing Secret + +
+ + +
+
+ + Used to verify webhook payload authenticity + + +
+ )} + /> + ( + + Events + + + Choose the events you want to receive notifications for + + + + )} + /> +
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx b/web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx new file mode 100644 index 0000000..a00cdc8 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/delete-webhook-button.tsx @@ -0,0 +1,50 @@ +'use client' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Trash2 } from 'lucide-react' + +interface DeleteWebhookButtonProps { + onDelete: () => void +} + +export function DeleteWebhookButton({ onDelete }: DeleteWebhookButtonProps) { + return ( + + + + + + + Delete Webhook + + Are you sure you want to delete this webhook? This action cannot be + undone. + + + + Cancel + + Delete + + + + + ) +} diff --git a/web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx b/web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx new file mode 100644 index 0000000..b0677e2 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/edit-webhook-dialog.tsx @@ -0,0 +1,201 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm } from 'react-hook-form' +import * as z from 'zod' +import { useEffect, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' +import { WebhookData } from '@/lib/types' +import { WEBHOOK_EVENTS } from '@/lib/constants' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { useToast } from '@/hooks/use-toast' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +const formSchema = z.object({ + deliveryUrl: z.string().url({ message: 'Please enter a valid URL' }), + events: z.array(z.string()).min(1, { message: 'Select at least one event' }), + isActive: z.boolean().default(true), + signingSecret: z.string().min(1, { message: 'Signing secret is required' }), +}) + +interface EditWebhookDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + webhook: WebhookData +} + +export function EditWebhookDialog({ + open, + onOpenChange, + webhook, +}: EditWebhookDialogProps) { + const queryClient = useQueryClient() + const { toast } = useToast() + + const form = useForm>({ + resolver: zodResolver(formSchema), + values: { + deliveryUrl: webhook.deliveryUrl, + events: webhook.events, + isActive: webhook.isActive, + signingSecret: webhook.signingSecret, + }, + }) + + const { mutate: updateWebhook, isPending } = useMutation({ + mutationFn: async (values: z.infer) => { + return httpBrowserClient.patch( + ApiEndpoints.gateway.updateWebhook(webhook._id), + values + ) + }, + onSuccess: () => { + toast({ + title: 'Success', + description: 'Webhook updated successfully', + }) + // Invalidate and refetch webhooks list + queryClient.invalidateQueries({ queryKey: ['webhooks'] }) + onOpenChange(false) + }, + onError: () => { + toast({ + title: 'Error', + description: 'Failed to update webhook', + variant: 'destructive', + }) + }, + }) + + const onSubmit = (values: z.infer) => { + updateWebhook(values) + } + + return ( + + + + Edit Webhook + + Update your webhook configuration. + + +
+ + ( + + Delivery URL + + + + + The URL where webhook notifications will be sent via POST + requests + + + + )} + /> + ( + + Signing Secret + +
+ + +
+
+ + Used to verify webhook payload authenticity + + +
+ )} + /> + ( + + Events + + + Choose the events you want to receive notifications for + + + + )} + /> +
+ + +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx b/web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx new file mode 100644 index 0000000..8c5a2f3 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/webhook-card.tsx @@ -0,0 +1,139 @@ +'use client' + +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { DeleteWebhookButton } from './delete-webhook-button' +import { Edit2, Eye, EyeOff } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { useState } from 'react' +import { useToast } from '@/hooks/use-toast' +import { CopyButton } from '@/components/shared/copy-button' +import { WebhookData } from '@/lib/types' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { useQueryClient } from '@tanstack/react-query' + +interface WebhookCardProps { + webhook: WebhookData + onEdit: () => void + onDelete?: () => void +} + +export function WebhookCard({ webhook, onEdit, onDelete }: WebhookCardProps) { + const { toast } = useToast() + const [isLoading, setIsLoading] = useState(false) + const queryClient = useQueryClient() + const [showSecret, setShowSecret] = useState(false) + + const handleToggle = async (checked: boolean) => { + setIsLoading(true) + try { + await httpBrowserClient.patch( + ApiEndpoints.gateway.updateWebhook(webhook._id), + { isActive: checked } + ) + + await queryClient.invalidateQueries({ + queryKey: ['webhooks'] + }) + + toast({ + title: `Webhook ${checked ? 'enabled' : 'disabled'}`, + description: `Webhook notifications are now ${ + checked ? 'enabled' : 'disabled' + }.`, + }) + } catch (error) { + toast({ + title: 'Error', + description: `Failed to ${checked ? 'enable' : 'disable'} webhook`, + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + const maskSecret = (secret: string) => { + // if the secret is less than 18 characters, show all + if (secret.length <= 18) { + return secret.slice(0, 18) + } + return secret.slice(0, 18) + '*'.repeat(secret.length - 24) + } + + return ( + + +
+
+

Webhook Endpoint

+ + {webhook.isActive ? 'Active' : 'Inactive'} + +
+

+ Notifications for SMS events +

+
+
+ + + +
+
+ +
+
+ +
+ + {webhook.deliveryUrl} + + +
+
+
+ +
+ + {showSecret ? webhook.signingSecret : maskSecret(webhook.signingSecret)} + + + +
+
+
+ +
+ {webhook.events.map((event) => ( + + {event} + + ))} +
+
+
+
+
+ ) +} diff --git a/web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx b/web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx new file mode 100644 index 0000000..c848ea5 --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/webhook-docs.tsx @@ -0,0 +1,169 @@ +'use client' + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Code } from '@/components/ui/code' +import { AlertCircle } from 'lucide-react' + +const SAMPLE_PAYLOAD = { + smsId: 'smsId', + sender: '+123456789', + message: 'message', + receivedAt: 'datetime', + deviceId: 'deviceId', + webhookSubscriptionId: 'webhookSubscriptionId', + webhookEvent: 'sms.received', +} + +const VERIFICATION_CODE = ` +// Node.js example using crypto +const crypto = require('crypto'); + +function verifyWebhookSignature(payload, signature, secret) { + const hmac = crypto.createHmac('sha256', secret); + const digest = hmac.update(JSON.stringify(payload)).digest('hex'); + const signatureHash = signature.split('=')[1]; + + return crypto.timingSafeEqual( + Buffer.from(signatureHash), + Buffer.from(digest) + ); +} + +// Express middleware example +app.post('/webhook', (req, res) => { + const signature = req.headers['x-signature']; + const payload = req.body; + + if (!verifyWebhookSignature(payload, signature, WEBHOOK_SECRET)) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + // Process the webhook + console.log('Webhook verified:', payload); + res.status(200).send('OK'); +}); +` + +const PYTHON_CODE = ` +# Python example using hmac +import hmac +import hashlib +import json +from flask import Flask, request + +app = Flask(__name__) + +def verify_signature(payload, signature, secret): + expected = hmac.new( + secret.encode('utf-8'), + json.dumps(payload).encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(signature.split('=')[1], expected) + +@app.route('/webhook', methods=['POST']) +def webhook(): + signature = request.headers.get('X-Signature') + if not verify_signature(request.json, signature, WEBHOOK_SECRET): + return 'Invalid signature', 401 + + # Process the webhook + print('Webhook verified:', request.json) + return 'OK', 200 +` + +export function WebhookDocs() { + return ( + + + +
+ + Webhook Delivery Information +
+
+ +
+

+ When a new SMS is received, we'll send a POST request to your + webhook URL with the event data. Your endpoint should: +

+
    +
  • Accept POST requests
  • +
  • Return a 2XX status code to acknowledge receipt
  • +
  • Process the request within 10 seconds
  • +
+

+ If we don't receive a successful response, we'll retry the + delivery at increasing intervals: 3 minutes, 5 minutes, 30 minutes, + 1 hour, 6 hours, 1 day, 3 days, 7 days, 30 days. +

+
+
+
+ + + +
+ + Security & Implementation Guide +
+
+ + + + Overview + Payload + Verification + + + +
+

Each webhook request includes:

+
    +
  • Payload in JSON format
  • +
  • X-Signature header for verification
  • +
  • + Signature format: sha256=HMAC_SHA256(payload, secret) +
  • +
+
+
+ + +
+

Sample Payload

+ {JSON.stringify(SAMPLE_PAYLOAD, null, 2)} +
+
+ + +
+ + + Node.js + Python + + + + {VERIFICATION_CODE} + + + + {PYTHON_CODE} + + +
+
+
+
+
+
+ ) +} diff --git a/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx b/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx new file mode 100644 index 0000000..acbae7f --- /dev/null +++ b/web/app/(app)/dashboard/(components)/webhooks/webhooks-section.tsx @@ -0,0 +1,178 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { PlusCircle, Webhook } from 'lucide-react' +import { useState } from 'react' +import { WebhookData } from '@/lib/types' +import { WebhookCard } from './webhook-card' +import { WebhookDocs } from './webhook-docs' +import { CreateWebhookDialog } from './create-webhook-dialog' +import { EditWebhookDialog } from './edit-webhook-dialog' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import httpBrowserClient from '@/lib/httpBrowserClient' +import { ApiEndpoints } from '@/config/api' +import { Skeleton } from '@/components/ui/skeleton' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +function WebhookCardSkeleton() { + return ( +
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ ) +} + +export default function WebhooksSection() { + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [selectedWebhook, setSelectedWebhook] = useState( + null + ) + const queryClient = useQueryClient() + + const { + data: webhooks, + isLoading, + error, + } = useQuery({ + queryKey: ['webhooks'], + queryFn: () => + httpBrowserClient + .get(ApiEndpoints.gateway.getWebhooks()) + .then((res) => res.data), + }) + + const handleCreateClick = () => { + setCreateDialogOpen(true) + } + + const handleEditClick = (webhook: WebhookData) => { + setSelectedWebhook(webhook) + setEditDialogOpen(true) + } + + return ( +
+
+
+

+ + Webhooks + + + + + BETA + + + +

This feature is in beta and may undergo changes. Use with caution in production environments.

+
+
+
+

+

+ Manage webhook notifications for your SMS events +

+
+ +
+ +
+
+ {isLoading ? ( +
+ + +
+ ) : error ? ( +
+ Error: {error.message} +
+ ) : webhooks?.data?.length > 0 ? ( +
+ {webhooks.data.map((webhook) => ( + handleEditClick(webhook)} + /> + ))} +
+ ) : ( +
+

No webhook configured

+

+ Create a webhook to receive real-time notifications for SMS events +

+ +
+ )} +
+ +
+ +
+
+ +
+ +
+ + + + {selectedWebhook && ( + + )} +
+ ) +} diff --git a/web/components/shared/copy-button.tsx b/web/components/shared/copy-button.tsx new file mode 100644 index 0000000..7d30011 --- /dev/null +++ b/web/components/shared/copy-button.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { useToast } from "@/hooks/use-toast"; + +interface CopyButtonProps { + value: string; + label: string; +} + +export function CopyButton({ value, label }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + const { toast } = useToast(); + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + toast({ + title: "Copied!", + description: `${label} copied to clipboard`, + }); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + toast({ + title: "Failed to copy", + description: "Please try again", + variant: "destructive", + }); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/web/components/ui/alert-dialog.tsx b/web/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..57760f2 --- /dev/null +++ b/web/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web/components/ui/code.tsx b/web/components/ui/code.tsx new file mode 100644 index 0000000..d9f7b91 --- /dev/null +++ b/web/components/ui/code.tsx @@ -0,0 +1,17 @@ +import { cn } from "@/lib/utils"; + +interface CodeProps extends React.HTMLAttributes {} + +export function Code({ className, children, ...props }: CodeProps) { + return ( +
+      {children}
+    
+ ); +} \ No newline at end of file diff --git a/web/components/ui/skeleton.tsx b/web/components/ui/skeleton.tsx new file mode 100644 index 0000000..d7e45f7 --- /dev/null +++ b/web/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/web/components/ui/tooltip.tsx b/web/components/ui/tooltip.tsx new file mode 100644 index 0000000..a66b3f2 --- /dev/null +++ b/web/components/ui/tooltip.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/web/config/api.ts b/web/config/api.ts index 472b907..d6a9c1b 100644 --- a/web/config/api.ts +++ b/web/config/api.ts @@ -23,6 +23,9 @@ export const ApiEndpoints = { sendBulkSMS: (id: string) => `/gateway/devices/${id}/send-bulk-sms`, getReceivedSMS: (id: string) => `/gateway/devices/${id}/get-received-sms`, + getWebhooks: () => '/webhooks', + createWebhook: () => '/webhooks', + updateWebhook: (id: string) => `/webhooks/${id}`, getStats: () => '/gateway/stats', }, } diff --git a/web/lib/constants.ts b/web/lib/constants.ts new file mode 100644 index 0000000..9967356 --- /dev/null +++ b/web/lib/constants.ts @@ -0,0 +1,3 @@ +export const WEBHOOK_EVENTS = { + MESSAGE_RECEIVED: 'MESSAGE_RECEIVED', +} as const diff --git a/web/lib/types.ts b/web/lib/types.ts new file mode 100644 index 0000000..8378f6e --- /dev/null +++ b/web/lib/types.ts @@ -0,0 +1,17 @@ +export interface WebhookData { + _id?: string + deliveryUrl: string + events: string[] + isActive: boolean + signingSecret: string +} + +export interface WebhookPayload { + smsId: string + sender: string + message: string + receivedAt: string + deviceId: string + webhookSubscriptionId: string + webhookEvent: string +} diff --git a/web/package.json b/web/package.json index 2cb9e1c..b920eab 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^3.9.1", "@prisma/client": "^5.22.0", "@radix-ui/react-accordion": "^1.2.1", + "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -28,6 +29,7 @@ "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.6", "@react-oauth/google": "^0.12.1", "@tanstack/react-query": "^5.61.0", "axios": "^1.6.5", @@ -48,6 +50,7 @@ "react-syntax-highlighter": "^15.5.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.3", "zod": "^3.23.8" }, "engines": { diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index ee66ea1..8c06c5e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-accordion': specifier: ^1.2.1 version: 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.4 + version: 1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-avatar': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -56,6 +59,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.1.6 + version: 1.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-oauth/google': specifier: ^0.12.1 version: 0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -116,6 +122,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) + uuid: + specifier: ^11.0.3 + version: 11.0.3 zod: specifier: ^3.23.8 version: 3.23.8 @@ -348,6 +357,9 @@ packages: '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/react-accordion@1.2.1': resolution: {integrity: sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==} peerDependencies: @@ -361,6 +373,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-alert-dialog@1.1.4': + resolution: {integrity: sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.0': resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==} peerDependencies: @@ -374,6 +399,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.1': + resolution: {integrity: sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-avatar@1.1.1': resolution: {integrity: sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==} peerDependencies: @@ -435,6 +473,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.0': resolution: {integrity: sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==} peerDependencies: @@ -466,6 +513,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.4': + resolution: {integrity: sha512-Ur7EV1IwQGCyaAuyDRiOLA5JIUZxELJljF+MbM/2NC0BYwfuRrbpS30BiQBJrVruscgUkieKkqXYDOoByaxIoA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.0': resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -488,6 +548,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.3': + resolution: {integrity: sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dropdown-menu@2.1.2': resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} peerDependencies: @@ -523,6 +596,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.1': + resolution: {integrity: sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-icons@1.3.0': resolution: {integrity: sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==} peerDependencies: @@ -589,6 +675,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.2.1': + resolution: {integrity: sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.2': resolution: {integrity: sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==} peerDependencies: @@ -602,6 +701,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.3': + resolution: {integrity: sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.1': resolution: {integrity: sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==} peerDependencies: @@ -615,6 +727,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.0.0': resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -628,6 +753,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.1': + resolution: {integrity: sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.0': resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} peerDependencies: @@ -676,6 +814,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.1.1': + resolution: {integrity: sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-switch@1.1.1': resolution: {integrity: sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==} peerDependencies: @@ -715,6 +862,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.1.6': + resolution: {integrity: sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -791,6 +951,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.1.1': + resolution: {integrity: sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} @@ -2081,6 +2254,16 @@ packages: '@types/react': optional: true + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-remove-scroll@2.6.0: resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} engines: {node: '>=10'} @@ -2091,6 +2274,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.6.2: + resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-style-singleton@2.2.1: resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} engines: {node: '>=10'} @@ -2101,6 +2294,16 @@ packages: '@types/react': optional: true + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-syntax-highlighter@15.5.0: resolution: {integrity: sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==} peerDependencies: @@ -2384,6 +2587,16 @@ packages: '@types/react': optional: true + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sidecar@1.1.2: resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} @@ -2397,6 +2610,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -2621,6 +2838,8 @@ snapshots: '@radix-ui/primitive@1.1.0': {} + '@radix-ui/primitive@1.1.1': {} + '@radix-ui/react-accordion@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -2638,6 +2857,20 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-alert-dialog@1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -2647,6 +2880,15 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-arrow@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-avatar@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) @@ -2709,6 +2951,12 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + '@radix-ui/react-compose-refs@1.1.1(@types/react@18.2.48)(react@18.2.0)': + dependencies: + react: 18.2.0 + optionalDependencies: + '@types/react': 18.2.48 + '@radix-ui/react-context@1.1.0(@types/react@18.2.48)(react@18.2.0)': dependencies: react: 18.2.0 @@ -2743,6 +2991,28 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-dialog@1.1.4(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0) + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.6.2(@types/react@18.2.48)(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-direction@1.1.0(@types/react@18.2.48)(react@18.2.0)': dependencies: react: 18.2.0 @@ -2762,6 +3032,19 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-dismissable-layer@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.0(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -2794,6 +3077,17 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-focus-scope@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-icons@1.3.0(react@18.2.0)': dependencies: react: 18.2.0 @@ -2880,6 +3174,24 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-popper@1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-arrow': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-rect': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/rect': 1.1.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-portal@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -2890,6 +3202,16 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-portal@1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-presence@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.48)(react@18.2.0) @@ -2900,6 +3222,16 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-presence@1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.2.48)(react@18.2.0) @@ -2909,6 +3241,15 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-primitive@2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -2979,6 +3320,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + '@radix-ui/react-slot@1.1.1(@types/react@18.2.48)(react@18.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + react: 18.2.0 + optionalDependencies: + '@types/react': 18.2.48 + '@radix-ui/react-switch@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -3030,6 +3378,26 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-tooltip@1.1.6(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-popper': 1.2.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-portal': 1.1.3(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-slot': 1.1.1(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.48)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.2.48)(react@18.2.0)': dependencies: react: 18.2.0 @@ -3085,6 +3453,15 @@ snapshots: '@types/react': 18.2.48 '@types/react-dom': 18.2.18 + '@radix-ui/react-visually-hidden@1.1.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.2.18)(@types/react@18.2.48)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + '@types/react-dom': 18.2.18 + '@radix-ui/rect@1.1.0': {} '@react-oauth/google@0.12.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': @@ -4525,6 +4902,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + react-remove-scroll-bar@2.3.8(@types/react@18.2.48)(react@18.2.0): + dependencies: + react: 18.2.0 + react-style-singleton: 2.2.3(@types/react@18.2.48)(react@18.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.48 + react-remove-scroll@2.6.0(@types/react@18.2.48)(react@18.2.0): dependencies: react: 18.2.0 @@ -4536,6 +4921,17 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + react-remove-scroll@2.6.2(@types/react@18.2.48)(react@18.2.0): + dependencies: + react: 18.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@18.2.48)(react@18.2.0) + react-style-singleton: 2.2.1(@types/react@18.2.48)(react@18.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.2.48)(react@18.2.0) + use-sidecar: 1.1.2(@types/react@18.2.48)(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.48 + react-style-singleton@2.2.1(@types/react@18.2.48)(react@18.2.0): dependencies: get-nonce: 1.0.1 @@ -4545,6 +4941,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + react-style-singleton@2.2.3(@types/react@18.2.48)(react@18.2.0): + dependencies: + get-nonce: 1.0.1 + react: 18.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.48 + react-syntax-highlighter@15.5.0(react@18.2.0): dependencies: '@babel/runtime': 7.23.8 @@ -4878,6 +5282,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.48 + use-callback-ref@1.3.3(@types/react@18.2.48)(react@18.2.0): + dependencies: + react: 18.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.2.48 + use-sidecar@1.1.2(@types/react@18.2.48)(react@18.2.0): dependencies: detect-node-es: 1.1.0 @@ -4888,6 +5299,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@11.0.3: {} + uuid@8.3.2: {} which-boxed-primitive@1.0.2: From a61c1c4ce8434a2b552fda12bcb0172ca57f7849 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 22 Dec 2024 01:03:24 +0300 Subject: [PATCH 3/5] fix(api): fix webhook notification schema type issue --- api/src/webhook/schemas/webhook-notification.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/webhook/schemas/webhook-notification.schema.ts b/api/src/webhook/schemas/webhook-notification.schema.ts index 3e366dc..f16a9f1 100644 --- a/api/src/webhook/schemas/webhook-notification.schema.ts +++ b/api/src/webhook/schemas/webhook-notification.schema.ts @@ -21,7 +21,7 @@ export class WebhookNotification { @Prop({ type: Types.ObjectId, ref: SMS.name }) sms: SMS - @Prop({ type: String }) + @Prop({ type: Date }) deliveredAt: Date @Prop({ type: Date }) From 717e4fb1a184facf1ce457bdd9bf9ba1bd750d25 Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 22 Dec 2024 01:03:57 +0300 Subject: [PATCH 4/5] chore(api): make user field required in webhook subscription schema --- api/src/webhook/schemas/webhook-subscription.schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/webhook/schemas/webhook-subscription.schema.ts b/api/src/webhook/schemas/webhook-subscription.schema.ts index 3e69d04..d7703b6 100644 --- a/api/src/webhook/schemas/webhook-subscription.schema.ts +++ b/api/src/webhook/schemas/webhook-subscription.schema.ts @@ -9,7 +9,7 @@ export type WebhookSubscriptionDocument = WebhookSubscription & Document export class WebhookSubscription { _id?: Types.ObjectId - @Prop({ type: Types.ObjectId, ref: User.name }) + @Prop({ type: Types.ObjectId, ref: User.name, required: true }) user: User @Prop({ type: Boolean, default: true }) From 409ac5f5cdd260e7a961eb158e5e744b6496a10e Mon Sep 17 00:00:00 2001 From: isra el Date: Sun, 22 Dec 2024 01:06:34 +0300 Subject: [PATCH 5/5] chore(api): handle webhook related edgecases and cleanup --- api/src/gateway/gateway.module.ts | 2 ++ api/src/gateway/gateway.service.ts | 14 +++++++-- api/src/webhook/webhook.service.ts | 49 +++++++++++++++++++++--------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/api/src/gateway/gateway.module.ts b/api/src/gateway/gateway.module.ts index ca088da..a3caba3 100644 --- a/api/src/gateway/gateway.module.ts +++ b/api/src/gateway/gateway.module.ts @@ -7,6 +7,7 @@ import { AuthModule } from '../auth/auth.module' import { UsersModule } from '../users/users.module' import { SMS, SMSSchema } from './schemas/sms.schema' import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema' +import { WebhookModule } from 'src/webhook/webhook.module' @Module({ imports: [ @@ -26,6 +27,7 @@ import { SMSBatch, SMSBatchSchema } from './schemas/sms-batch.schema' ]), AuthModule, UsersModule, + WebhookModule, ], controllers: [GatewayController], providers: [GatewayService], diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 3db06c5..8e0d039 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -19,6 +19,8 @@ import { BatchResponse, Message, } from 'firebase-admin/lib/messaging/messaging-api' +import { WebhookEvent } from 'src/webhook/webhook-event.enum' +import { WebhookService } from 'src/webhook/webhook.service' @Injectable() export class GatewayService { constructor( @@ -26,6 +28,7 @@ export class GatewayService { @InjectModel(SMS.name) private smsModel: Model, @InjectModel(SMSBatch.name) private smsBatchModel: Model, private authService: AuthService, + private webhookService: WebhookService, ) {} async registerDevice( @@ -343,7 +346,7 @@ export class GatewayService { console.log(e) } } - + const successCount = fcmResponses.reduce( (acc, m) => acc + m.successCount, 0, @@ -411,7 +414,14 @@ export class GatewayService { console.log(e) }) - // TODO: Implement webhook to forward received SMS to user's callback URL + this.webhookService.deliverNotification({ + sms, + user: device.user, + event: WebhookEvent.MESSAGE_RECEIVED, + }) + .catch((e) => { + console.log(e) + }) return sms } diff --git a/api/src/webhook/webhook.service.ts b/api/src/webhook/webhook.service.ts index 2fae90b..52468d9 100644 --- a/api/src/webhook/webhook.service.ts +++ b/api/src/webhook/webhook.service.ts @@ -42,7 +42,7 @@ export class WebhookService { } async create({ user, createWebhookDto }) { - const { events, deliveryUrl } = createWebhookDto + const { events, deliveryUrl, signingSecret } = createWebhookDto // Add URL validation try { @@ -51,6 +51,11 @@ export class WebhookService { throw new HttpException('Invalid delivery URL', HttpStatus.BAD_REQUEST) } + // validate signing secret + if (signingSecret.length < 20) { + throw new HttpException('Invalid signing secret', HttpStatus.BAD_REQUEST) + } + const existingSubscription = await this.webhookSubscriptionModel.findOne({ user: user._id, events, @@ -67,8 +72,6 @@ export class WebhookService { throw new HttpException('Invalid event type', HttpStatus.BAD_REQUEST) } - const signingSecret = uuidv4() - // TODO: Encrypt signing secret // const webhookSignatureKey = process.env.WEBHOOK_SIGNATURE_KEY // const encryptedSigningSecret = encrypt(signingSecret, webhookSignatureKey) @@ -117,16 +120,13 @@ export class WebhookService { } async deliverNotification({ sms, user, event }) { - console.log('deliverNotification') - console.log(sms) - console.log(user) - console.log(event) const webhookSubscription = await this.webhookSubscriptionModel.findOne({ user: user._id, events: { $in: [event] }, + isActive: true, }) - if (!webhookSubscription || !webhookSubscription.isActive) { + if (!webhookSubscription) { return } @@ -161,6 +161,23 @@ export class WebhookService { const webhookSubscription = await this.webhookSubscriptionModel.findById( webhookNotification.webhookSubscription, ) + + if (!webhookSubscription) { + console.error( + `Webhook subscription not found for ${webhookNotification._id}`, + ) + return + } + + if (!webhookSubscription.isActive) { + webhookNotification.deliveryAttemptAbortedAt = now + await webhookNotification.save() + console.error( + `Webhook subscription is not active for ${webhookNotification._id}, aborting delivery`, + ) + return + } + const deliveryUrl = webhookSubscription?.deliveryUrl const signingSecret = webhookSubscription?.signingSecret @@ -184,7 +201,6 @@ export class WebhookService { webhookNotification.deliveredAt = now await webhookNotification.save() - webhookSubscription.successfulDeliveryCount += 1 webhookSubscription.lastDeliverySuccessAt = now } catch (e) { @@ -197,7 +213,6 @@ export class WebhookService { webhookNotification.deliveryAttemptCount, ) await webhookNotification.save() - } finally { webhookSubscription.deliveryAttemptCount += 1 await webhookSubscription.save() @@ -205,9 +220,9 @@ export class WebhookService { } private getNextDeliveryAttemptAt(deliveryAttemptCount: number): Date { - // Delays in minutes + // Delays in minutes after a failed delivery attempt const delaySequence = [ - 1, // 1 minute + 3, // 3 minutes 5, // 5 minutes 30, // 30 minutes 60, // 1 hour @@ -228,8 +243,8 @@ export class WebhookService { return new Date(Date.now() + delayInMinutes * 60 * 1000) } - // Check for notifications that need to be delivered every minute - @Cron(CronExpression.EVERY_MINUTE) + // Check for notifications that need to be delivered every 3 minutes + @Cron('0 */3 * * * *') async checkForNotificationsToDeliver() { const now = new Date() const notifications = await this.webhookNotificationModel @@ -240,7 +255,11 @@ export class WebhookService { deliveryAttemptAbortedAt: null, }) .sort({ nextDeliveryAttemptAt: 1 }) - .limit(50) + .limit(30) + + if (notifications.length === 0) { + return + } console.log(`delivering ${notifications.length} webhook notifications`)