-
-
-
-
-
+ <>
+
+
+
+
+
+
+
+
+ {hideFooter ? null :
}
- {hideFooter ? null :
}
-
+
+ >
);
}
diff --git a/app/routes/webhook/message.ts b/app/routes/webhook/message.ts
index 69841e3..cffa51b 100644
--- a/app/routes/webhook/message.ts
+++ b/app/routes/webhook/message.ts
@@ -1,13 +1,16 @@
import { type ActionFunction } from "@remix-run/node";
import { badRequest, html, notFound, serverError } from "remix-utils";
+import twilio from "twilio";
+import { z } from "zod";
import { Prisma, SubscriptionStatus } from "@prisma/client";
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
+import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
import logger from "~/utils/logger.server";
import db from "~/utils/db.server";
-import twilio from "twilio";
import { smsUrl } from "~/utils/twilio.server";
import { decrypt } from "~/utils/encryption";
+import { validate } from "~/utils/validation.server";
export const action: ActionFunction = async ({ request }) => {
const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature");
@@ -15,7 +18,14 @@ export const action: ActionFunction = async ({ request }) => {
return badRequest("Invalid header X-Twilio-Signature");
}
- const body: Body = Object.fromEntries(await request.formData()) as any;
+ const formData = Object.fromEntries(await request.formData());
+ const validation = validate(bodySchema, formData);
+ if (validation.errors) {
+ logger.error(validation.errors);
+ return badRequest("");
+ }
+
+ const body = validation.data;
try {
const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To },
@@ -47,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
return notFound("Phone number not found");
}
- const phoneNumbersWithActiveSub = phoneNumbers.filter(
+ /*const phoneNumbersWithActiveSub = phoneNumbers.filter(
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
);
if (phoneNumbersWithActiveSub.length === 0) {
@@ -55,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => {
// because the organization is on the free plan
console.log("no active subscription"); // TODO: uncomment the line below -- beware: refresh phone numbers refetch those missed messages lol
// return html("
");
- }
+ }*/
const phoneNumber = phoneNumbers.find((phoneNumber) => {
// TODO: uncomment the line below
@@ -65,7 +75,7 @@ export const action: ActionFunction = async ({ request }) => {
// maybe we shouldn't let that happen by restricting a phone number to one org?
const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
- return twilio.validateRequest(authToken, twilioSignature, smsUrl, body);
+ return twilio.validateRequest(authToken, twilioSignature, smsUrl, formData);
});
if (!phoneNumber) {
return badRequest("Invalid webhook");
@@ -73,10 +83,16 @@ export const action: ActionFunction = async ({ request }) => {
const messageSid = body.MessageSid;
const phoneNumberId = phoneNumber.id;
- await insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
- messageSid,
- phoneNumberId,
- });
+ await Promise.all([
+ insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
+ messageSid,
+ phoneNumberId,
+ }),
+ notifyIncomingMessageQueue.add(`notify incoming message ${messageSid} for ${phoneNumberId}`, {
+ messageSid,
+ phoneNumberId,
+ }),
+ ]);
return html("
");
} catch (error: any) {
@@ -86,24 +102,25 @@ export const action: ActionFunction = async ({ request }) => {
}
};
-type Body = {
- ToCountry: string;
- ToState: string;
- SmsMessageSid: string;
- NumMedia: string;
- ToCity: string;
- FromZip: string;
- SmsSid: string;
- FromState: string;
- SmsStatus: string;
- FromCity: string;
- Body: string;
- FromCountry: string;
- To: string;
- ToZip: string;
- NumSegments: string;
- MessageSid: string;
- AccountSid: string;
- From: string;
- ApiVersion: string;
-};
+const bodySchema = z.object({
+ MessageSid: z.string(),
+ To: z.string(),
+ ToCountry: z.string().optional(),
+ ToState: z.string().optional(),
+ SmsMessageSid: z.string().optional(),
+ NumMedia: z.string().optional(),
+ ToCity: z.string().optional(),
+ FromZip: z.string().optional(),
+ SmsSid: z.string().optional(),
+ FromState: z.string().optional(),
+ SmsStatus: z.string().optional(),
+ FromCity: z.string().optional(),
+ Body: z.string().optional(),
+ FromCountry: z.string().optional(),
+ ToZip: z.string().optional(),
+ NumSegments: z.string().optional(),
+ AccountSid: z.string().optional(),
+ From: z.string().optional(),
+ ApiVersion: z.string().optional(),
+ ReferralNumMedia: z.string().optional(),
+});
diff --git a/app/service-worker/push.ts b/app/service-worker/push.ts
index 24d3c8f..363f0ad 100644
--- a/app/service-worker/push.ts
+++ b/app/service-worker/push.ts
@@ -12,8 +12,17 @@ const defaultOptions: NotificationOptions = {
};
export default async function handlePush(event: PushEvent) {
- const { title, ...payload }: NotificationPayload = event.data!.json();
+ const payload: NotificationPayload = event.data!.json();
const options = Object.assign({}, defaultOptions, payload);
- await self.registration.showNotification(title, options);
- await addBadge(1);
+
+ const clients = await self.clients.matchAll({ type: "window" });
+ const hasOpenTab = clients.some((client) => client.focused === true);
+ if (hasOpenTab) {
+ const channel = new BroadcastChannel("notifications");
+ channel.postMessage(JSON.stringify(payload));
+ channel.close();
+ } else {
+ await self.registration.showNotification(payload.title, options);
+ await addBadge(1);
+ }
}
diff --git a/app/utils/web-push.server.ts b/app/utils/web-push.server.ts
index 98cffd7..95b8799 100644
--- a/app/utils/web-push.server.ts
+++ b/app/utils/web-push.server.ts
@@ -1,13 +1,20 @@
import webpush, { type PushSubscription, WebPushError } from "web-push";
-import type { NotificationSubscription } from "@prisma/client";
+import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
+import { parsePhoneNumber } from "awesome-phonenumber";
+import { type NotificationSubscription, Direction } from "@prisma/client";
import serverConfig from "~/config/config.server";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
+import { translateMessageDirection } from "~/utils/twilio.server";
-export type NotificationPayload = NotificationOptions & {
+export type NotificationPayload = Omit
& {
title: string; // max 50 characters
body: string; // max 150 characters
+ data: {
+ recipient: string;
+ type: "message" | "call";
+ };
};
export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) {
@@ -50,3 +57,19 @@ function truncate(str: string, maxLength: number) {
return str.substring(0, maxLength - 1) + "\u2026";
}
+
+export function buildMessageNotificationPayload(message: MessageInstance): NotificationPayload {
+ const direction = translateMessageDirection(message.direction);
+ const recipient = direction === Direction.Outbound ? message.to : message.from;
+ return {
+ title: parsePhoneNumber(recipient).getNumber("international"),
+ body: message.body,
+ actions: [
+ {
+ action: "reply",
+ title: "Reply",
+ },
+ ],
+ data: { recipient, type: "message" },
+ };
+}