multi tenancy stuff
This commit is contained in:
parent
b54f9ef43c
commit
d20eeb0617
@ -1,6 +1,6 @@
|
|||||||
import { resolver, SecurePassword, AuthenticationError } from "blitz";
|
import { resolver, SecurePassword, AuthenticationError } from "blitz";
|
||||||
|
|
||||||
import db, { Role } from "../../../db";
|
import db, { GlobalRole } from "../../../db";
|
||||||
import { Login } from "../validations";
|
import { Login } from "../validations";
|
||||||
|
|
||||||
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
|
export const authenticateUser = async (rawEmail: string, rawPassword: string) => {
|
||||||
@ -25,7 +25,13 @@ export default resolver.pipe(resolver.zod(Login), async ({ email, password }, ct
|
|||||||
// This throws an error if credentials are invalid
|
// This throws an error if credentials are invalid
|
||||||
const user = await authenticateUser(email, password);
|
const user = await authenticateUser(email, password);
|
||||||
|
|
||||||
await ctx.session.$create({ userId: user.id, role: user.role as Role });
|
const hasCompletedOnboarding = undefined; // TODO
|
||||||
|
await ctx.session.$create({
|
||||||
|
userId: user.id,
|
||||||
|
roles: [user.role],
|
||||||
|
hasCompletedOnboarding,
|
||||||
|
orgId: "user.memberships[0].organizationId",
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
|
@ -1,18 +1,35 @@
|
|||||||
import { resolver, SecurePassword } from "blitz";
|
import { resolver, SecurePassword } from "blitz";
|
||||||
|
|
||||||
import db, { Role } from "../../../db";
|
import db, { GlobalRole, MembershipRole } from "../../../db";
|
||||||
import { Signup } from "../validations";
|
import { Signup } from "../validations";
|
||||||
import { computeEncryptionKey } from "../../../db/_encryption";
|
import { computeEncryptionKey } from "../../../db/_encryption";
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(Signup), async ({ email, password }, ctx) => {
|
export default resolver.pipe(resolver.zod(Signup), async ({ email, password }, ctx) => {
|
||||||
const hashedPassword = await SecurePassword.hash(password.trim());
|
const hashedPassword = await SecurePassword.hash(password.trim());
|
||||||
|
const encryptionKey = computeEncryptionKey(email.toLowerCase().trim()).toString("hex");
|
||||||
const user = await db.user.create({
|
const user = await db.user.create({
|
||||||
data: { email: email.toLowerCase().trim(), hashedPassword, role: Role.USER },
|
data: {
|
||||||
select: { id: true, name: true, email: true, role: true },
|
email: email.toLowerCase().trim(),
|
||||||
|
hashedPassword,
|
||||||
|
role: GlobalRole.CUSTOMER,
|
||||||
|
memberships: {
|
||||||
|
create: {
|
||||||
|
role: MembershipRole.OWNER,
|
||||||
|
organization: {
|
||||||
|
create: {
|
||||||
|
encryptionKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { memberships: true },
|
||||||
});
|
});
|
||||||
const encryptionKey = computeEncryptionKey(user.id).toString("hex");
|
|
||||||
await db.customer.create({ data: { id: user.id, encryptionKey } });
|
|
||||||
|
|
||||||
await ctx.session.$create({ userId: user.id, role: user.role });
|
await ctx.session.$create({
|
||||||
|
userId: user.id,
|
||||||
|
roles: [user.role, user.memberships[0]!.role],
|
||||||
|
orgId: user.memberships[0]!.organizationId,
|
||||||
|
});
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
import { useSession, useQuery } from "blitz";
|
|
||||||
|
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
|
||||||
|
|
||||||
export default function useCurrentCustomer() {
|
|
||||||
const session = useSession();
|
|
||||||
const [customer] = useQuery(getCurrentCustomer, null, { enabled: Boolean(session.userId) });
|
|
||||||
return {
|
|
||||||
customer,
|
|
||||||
hasFilledTwilioCredentials: Boolean(customer && customer.accountSid && customer.authToken),
|
|
||||||
hasCompletedOnboarding: session.hasCompletedOnboarding,
|
|
||||||
};
|
|
||||||
}
|
|
11
app/core/hooks/use-current-phone-number.ts
Normal file
11
app/core/hooks/use-current-phone-number.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from "blitz";
|
||||||
|
|
||||||
|
import getCurrentPhoneNumber from "../../phone-numbers/queries/get-current-phone-number";
|
||||||
|
import useCurrentUser from "./use-current-user";
|
||||||
|
|
||||||
|
export default function useUserPhoneNumber() {
|
||||||
|
const { hasFilledTwilioCredentials } = useCurrentUser();
|
||||||
|
const [phoneNumber] = useQuery(getCurrentPhoneNumber, {}, { enabled: hasFilledTwilioCredentials });
|
||||||
|
|
||||||
|
return phoneNumber;
|
||||||
|
}
|
15
app/core/hooks/use-current-user.ts
Normal file
15
app/core/hooks/use-current-user.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useSession, useQuery } from "blitz";
|
||||||
|
|
||||||
|
import getCurrentUser from "../../users/queries/get-current-user";
|
||||||
|
|
||||||
|
export default function useCurrentUser() {
|
||||||
|
const session = useSession();
|
||||||
|
const [user] = useQuery(getCurrentUser, null, { enabled: Boolean(session.userId) });
|
||||||
|
const organization = user?.memberships[0]!.organization;
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
organization,
|
||||||
|
hasFilledTwilioCredentials: Boolean(user && organization?.twilioAccountSid && organization?.twilioAuthToken),
|
||||||
|
hasCompletedOnboarding: session.hasCompletedOnboarding,
|
||||||
|
};
|
||||||
|
}
|
@ -1,12 +0,0 @@
|
|||||||
import { useQuery } from "blitz";
|
|
||||||
|
|
||||||
import getCurrentCustomerPhoneNumber from "../../phone-numbers/queries/get-current-customer-phone-number";
|
|
||||||
import useCurrentCustomer from "./use-current-customer";
|
|
||||||
|
|
||||||
export default function useCustomerPhoneNumber() {
|
|
||||||
const { customer } = useCurrentCustomer();
|
|
||||||
const hasFilledTwilioCredentials = Boolean(customer && customer.accountSid && customer.authToken);
|
|
||||||
const [customerPhoneNumber] = useQuery(getCurrentCustomerPhoneNumber, {}, { enabled: hasFilledTwilioCredentials });
|
|
||||||
|
|
||||||
return customerPhoneNumber;
|
|
||||||
}
|
|
@ -2,6 +2,7 @@ import { getConfig, useMutation } from "blitz";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import setNotificationSubscription from "../mutations/set-notification-subscription";
|
import setNotificationSubscription from "../mutations/set-notification-subscription";
|
||||||
|
import useCurrentPhoneNumber from "./use-current-phone-number";
|
||||||
|
|
||||||
const { publicRuntimeConfig } = getConfig();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ export default function useNotifications() {
|
|||||||
const isServiceWorkerSupported = "serviceWorker" in navigator;
|
const isServiceWorkerSupported = "serviceWorker" in navigator;
|
||||||
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
|
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
|
||||||
const [setNotificationSubscriptionMutation] = useMutation(setNotificationSubscription);
|
const [setNotificationSubscriptionMutation] = useMutation(setNotificationSubscription);
|
||||||
|
const phoneNumber = useCurrentPhoneNumber();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -23,7 +25,7 @@ export default function useNotifications() {
|
|||||||
}, [isServiceWorkerSupported]);
|
}, [isServiceWorkerSupported]);
|
||||||
|
|
||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
if (!isServiceWorkerSupported) {
|
if (!isServiceWorkerSupported || !phoneNumber) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +35,10 @@ export default function useNotifications() {
|
|||||||
applicationServerKey: urlBase64ToUint8Array(publicRuntimeConfig.webPush.publicKey),
|
applicationServerKey: urlBase64ToUint8Array(publicRuntimeConfig.webPush.publicKey),
|
||||||
});
|
});
|
||||||
setSubscription(subscription);
|
setSubscription(subscription);
|
||||||
await setNotificationSubscriptionMutation({ subscription: subscription.toJSON() as any }); // TODO remove as any
|
await setNotificationSubscriptionMutation({
|
||||||
|
phoneNumberId: phoneNumber.id,
|
||||||
|
subscription: subscription.toJSON() as any,
|
||||||
|
}); // TODO remove as any
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unsubscribe() {
|
async function unsubscribe() {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { Routes, useRouter } from "blitz";
|
import { Routes, useRouter } from "blitz";
|
||||||
|
|
||||||
import useCurrentCustomer from "./use-current-customer";
|
import useCurrentUser from "./use-current-user";
|
||||||
import useCustomerPhoneNumber from "./use-customer-phone-number";
|
import useCurrentPhoneNumber from "./use-current-phone-number";
|
||||||
|
|
||||||
export default function useRequireOnboarding() {
|
export default function useRequireOnboarding() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { hasFilledTwilioCredentials, hasCompletedOnboarding } = useCurrentCustomer();
|
const { hasFilledTwilioCredentials, hasCompletedOnboarding } = useCurrentUser();
|
||||||
const customerPhoneNumber = useCustomerPhoneNumber();
|
const phoneNumber = useCurrentPhoneNumber();
|
||||||
|
|
||||||
if (hasCompletedOnboarding) {
|
if (hasCompletedOnboarding) {
|
||||||
return;
|
return;
|
||||||
@ -16,12 +16,12 @@ export default function useRequireOnboarding() {
|
|||||||
throw router.push(Routes.StepTwo());
|
throw router.push(Routes.StepTwo());
|
||||||
}
|
}
|
||||||
|
|
||||||
/*if (!customer.paddleCustomerId || !customer.paddleSubscriptionId) {
|
/*if (!user.paddleCustomerId || !user.paddleSubscriptionId) {
|
||||||
throw router.push(Routes.StepTwo());
|
throw router.push(Routes.StepTwo());
|
||||||
return;
|
return;
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
if (!customerPhoneNumber) {
|
if (!phoneNumber) {
|
||||||
throw router.push(Routes.StepThree());
|
throw router.push(Routes.StepThree());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,6 +77,8 @@ const ErrorBoundary = withRouter(
|
|||||||
// let Blitz ErrorBoundary handle this one
|
// let Blitz ErrorBoundary handle this one
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if network error and connection lost, display the auto-reload page with countdown
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
@ -3,10 +3,13 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import db from "../../../db";
|
import db from "../../../db";
|
||||||
import appLogger from "../../../integrations/logger";
|
import appLogger from "../../../integrations/logger";
|
||||||
|
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../utils";
|
||||||
|
|
||||||
const logger = appLogger.child({ mutation: "set-notification-subscription" });
|
const logger = appLogger.child({ mutation: "set-notification-subscription" });
|
||||||
|
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
|
organizationId: z.string().optional(),
|
||||||
|
phoneNumberId: z.string(),
|
||||||
subscription: z.object({
|
subscription: z.object({
|
||||||
endpoint: z.string(),
|
endpoint: z.string(),
|
||||||
expirationTime: z.number().nullable(),
|
expirationTime: z.number().nullable(),
|
||||||
@ -17,12 +20,25 @@ const Body = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ subscription }, context) => {
|
export default resolver.pipe(
|
||||||
const customerId = context.session.userId;
|
resolver.zod(Body),
|
||||||
|
resolver.authorize(),
|
||||||
|
setDefaultOrganizationId,
|
||||||
|
enforceSuperAdminIfNotCurrentOrganization,
|
||||||
|
async ({ organizationId, phoneNumberId, subscription }) => {
|
||||||
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
|
where: { id: phoneNumberId, organizationId },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.notificationSubscription.create({
|
await db.notificationSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
expirationTime: subscription.expirationTime,
|
expirationTime: subscription.expirationTime,
|
||||||
keys_p256dh: subscription.keys.p256dh,
|
keys_p256dh: subscription.keys.p256dh,
|
||||||
@ -35,4 +51,5 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
|
|||||||
// we might want to `throw error`;
|
// we might want to `throw error`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
35
app/core/utils.ts
Normal file
35
app/core/utils.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { Ctx } from "blitz";
|
||||||
|
|
||||||
|
import type { Prisma } from "../../db";
|
||||||
|
import { GlobalRole } from "../../db";
|
||||||
|
|
||||||
|
function assert(condition: any, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDefaultOrganizationId<T extends Record<any, any>>(
|
||||||
|
input: T,
|
||||||
|
{ session }: Ctx,
|
||||||
|
): T & { organizationId: Prisma.StringNullableFilter | string } {
|
||||||
|
assert(session.orgId, "Missing session.orgId in setDefaultOrganizationId");
|
||||||
|
if (input.organizationId) {
|
||||||
|
// Pass through the input
|
||||||
|
return input as T & { organizationId: string };
|
||||||
|
} else if (session.roles?.includes(GlobalRole.SUPERADMIN)) {
|
||||||
|
// Allow viewing any organization
|
||||||
|
return { ...input, organizationId: { not: "" } };
|
||||||
|
} else {
|
||||||
|
// Set organizationId to session.orgId
|
||||||
|
return { ...input, organizationId: session.orgId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enforceSuperAdminIfNotCurrentOrganization<T extends Record<any, any>>(input: T, ctx: Ctx): T {
|
||||||
|
assert(ctx.session.orgId, "missing session.orgId");
|
||||||
|
assert(input.organizationId, "missing input.organizationId");
|
||||||
|
|
||||||
|
if (input.organizationId !== ctx.session.orgId) {
|
||||||
|
ctx.session.$authorize(GlobalRole.SUPERADMIN);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
@ -1,21 +0,0 @@
|
|||||||
import { resolver } from "blitz";
|
|
||||||
|
|
||||||
import db from "../../../db";
|
|
||||||
|
|
||||||
export default resolver.pipe(resolver.authorize(), async (_ = null, { session }) => {
|
|
||||||
if (!session.userId) return null;
|
|
||||||
|
|
||||||
return db.customer.findFirst({
|
|
||||||
where: { id: session.userId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
encryptionKey: true,
|
|
||||||
accountSid: true,
|
|
||||||
authToken: true,
|
|
||||||
twimlAppSid: true,
|
|
||||||
paddleCustomerId: true,
|
|
||||||
paddleSubscriptionId: true,
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
@ -5,21 +5,27 @@ import db from "../../../../db";
|
|||||||
import insertMessagesQueue from "./insert-messages";
|
import insertMessagesQueue from "./insert-messages";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ customerId }) => {
|
const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ organizationId, phoneNumberId }) => {
|
||||||
const [customer, phoneNumber] = await Promise.all([
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
db.customer.findFirst({ where: { id: customerId } }),
|
where: { id: phoneNumberId, organizationId },
|
||||||
db.phoneNumber.findFirst({ where: { customerId } }),
|
include: { organization: true },
|
||||||
]);
|
});
|
||||||
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
|
if (!phoneNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = phoneNumber.organization;
|
||||||
|
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sent, received] = await Promise.all([
|
const [sent, received] = await Promise.all([
|
||||||
twilio(customer.accountSid, customer.authToken).messages.list({ from: phoneNumber.phoneNumber }),
|
twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.list({ from: phoneNumber.number }),
|
||||||
twilio(customer.accountSid, customer.authToken).messages.list({ to: phoneNumber.phoneNumber }),
|
twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.list({ to: phoneNumber.number }),
|
||||||
]);
|
]);
|
||||||
const messagesSent = sent.filter((message) => message.direction.startsWith("outbound"));
|
const messagesSent = sent.filter((message) => message.direction.startsWith("outbound"));
|
||||||
const messagesReceived = received.filter((message) => message.direction === "inbound");
|
const messagesReceived = received.filter((message) => message.direction === "inbound");
|
||||||
@ -29,11 +35,12 @@ const fetchMessagesQueue = Queue<Payload>("api/queue/fetch-messages", async ({ c
|
|||||||
|
|
||||||
await insertMessagesQueue.enqueue(
|
await insertMessagesQueue.enqueue(
|
||||||
{
|
{
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
messages,
|
messages,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `insert-messages-${customerId}`,
|
id: `insert-messages-${organizationId}-${phoneNumberId}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -4,46 +4,49 @@ import twilio from "twilio";
|
|||||||
|
|
||||||
import db, { Direction, MessageStatus } from "../../../../db";
|
import db, { Direction, MessageStatus } from "../../../../db";
|
||||||
import { encrypt } from "../../../../db/_encryption";
|
import { encrypt } from "../../../../db/_encryption";
|
||||||
|
import notifyIncomingMessageQueue from "./notify-incoming-message";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
messageSid: MessageInstance["sid"];
|
messageSid: MessageInstance["sid"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertIncomingMessageQueue = Queue<Payload>(
|
const insertIncomingMessageQueue = Queue<Payload>(
|
||||||
"api/queue/insert-incoming-message",
|
"api/queue/insert-incoming-message",
|
||||||
async ({ messageSid, customerId }) => {
|
async ({ messageSid, organizationId, phoneNumberId }) => {
|
||||||
const customer = await db.customer.findFirst({ where: { id: customerId } });
|
const organization = await db.organization.findFirst({
|
||||||
if (!customer || !customer.accountSid || !customer.authToken) {
|
where: { id: organizationId },
|
||||||
|
});
|
||||||
|
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptionKey = customer.encryptionKey;
|
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken)
|
||||||
const message = await twilio(customer.accountSid, customer.authToken).messages.get(messageSid).fetch();
|
.messages.get(messageSid)
|
||||||
|
.fetch();
|
||||||
await db.message.create({
|
await db.message.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
|
id: messageSid,
|
||||||
to: message.to,
|
to: message.to,
|
||||||
from: message.from,
|
from: message.from,
|
||||||
status: translateStatus(message.status),
|
status: translateStatus(message.status),
|
||||||
direction: translateDirection(message.direction),
|
direction: translateDirection(message.direction),
|
||||||
sentAt: message.dateCreated,
|
sentAt: message.dateCreated,
|
||||||
content: encrypt(message.body, customer.encryptionKey),
|
content: encrypt(message.body, organization.encryptionKey),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.message.createMany({
|
await notifyIncomingMessageQueue.enqueue(
|
||||||
data: {
|
{
|
||||||
customerId,
|
messageSid,
|
||||||
content: encrypt(message.body, encryptionKey),
|
organizationId,
|
||||||
from: message.from,
|
phoneNumberId,
|
||||||
to: message.to,
|
|
||||||
status: translateStatus(message.status),
|
|
||||||
direction: translateDirection(message.direction),
|
|
||||||
twilioSid: message.sid,
|
|
||||||
sentAt: new Date(message.dateCreated),
|
|
||||||
},
|
},
|
||||||
});
|
{ id: `notify-${messageSid}-${organizationId}-${phoneNumberId}` },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -5,20 +5,27 @@ import db, { Direction, Message, MessageStatus } from "../../../../db";
|
|||||||
import { encrypt } from "../../../../db/_encryption";
|
import { encrypt } from "../../../../db/_encryption";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
messages: MessageInstance[];
|
messages: MessageInstance[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertMessagesQueue = Queue<Payload>("api/queue/insert-messages", async ({ messages, customerId }) => {
|
const insertMessagesQueue = Queue<Payload>(
|
||||||
const customer = await db.customer.findFirst({ where: { id: customerId } });
|
"api/queue/insert-messages",
|
||||||
if (!customer) {
|
async ({ messages, organizationId, phoneNumberId }) => {
|
||||||
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
|
where: { id: phoneNumberId, organizationId },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
if (!phoneNumber) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sms = messages
|
const sms = messages
|
||||||
.map<Omit<Message, "id">>((message) => ({
|
.map<Omit<Message, "id">>((message) => ({
|
||||||
customerId,
|
organizationId,
|
||||||
content: encrypt(message.body, customer.encryptionKey),
|
phoneNumberId: phoneNumber.id,
|
||||||
|
content: encrypt(message.body, phoneNumber.organization.encryptionKey),
|
||||||
from: message.from,
|
from: message.from,
|
||||||
to: message.to,
|
to: message.to,
|
||||||
status: translateStatus(message.status),
|
status: translateStatus(message.status),
|
||||||
@ -29,7 +36,8 @@ const insertMessagesQueue = Queue<Payload>("api/queue/insert-messages", async ({
|
|||||||
.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||||
|
|
||||||
await db.message.createMany({ data: sms });
|
await db.message.createMany({ data: sms });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export default insertMessagesQueue;
|
export default insertMessagesQueue;
|
||||||
|
|
||||||
|
@ -11,27 +11,32 @@ const { serverRuntimeConfig, publicRuntimeConfig } = getConfig();
|
|||||||
const logger = appLogger.child({ queue: "notify-incoming-message" });
|
const logger = appLogger.child({ queue: "notify-incoming-message" });
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
messageSid: MessageInstance["sid"];
|
messageSid: MessageInstance["sid"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const notifyIncomingMessageQueue = Queue<Payload>(
|
const notifyIncomingMessageQueue = Queue<Payload>(
|
||||||
"api/queue/notify-incoming-message",
|
"api/queue/notify-incoming-message",
|
||||||
async ({ messageSid, customerId }) => {
|
async ({ messageSid, organizationId, phoneNumberId }) => {
|
||||||
webpush.setVapidDetails(
|
webpush.setVapidDetails(
|
||||||
"mailto:mokht@rmi.al",
|
"mailto:mokht@rmi.al",
|
||||||
publicRuntimeConfig.webPush.publicKey,
|
publicRuntimeConfig.webPush.publicKey,
|
||||||
serverRuntimeConfig.webPush.privateKey,
|
serverRuntimeConfig.webPush.privateKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
const customer = await db.customer.findFirst({ where: { id: customerId } });
|
const organization = await db.organization.findFirst({
|
||||||
if (!customer || !customer.accountSid || !customer.authToken) {
|
where: { id: organizationId },
|
||||||
|
});
|
||||||
|
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await twilio(customer.accountSid, customer.authToken).messages.get(messageSid).fetch();
|
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken)
|
||||||
|
.messages.get(messageSid)
|
||||||
|
.fetch();
|
||||||
const notification = { message: `${message.from} - ${message.body}` };
|
const notification = { message: `${message.from} - ${message.body}` };
|
||||||
const subscriptions = await db.notificationSubscription.findMany({ where: { customerId: customer.id } });
|
const subscriptions = await db.notificationSubscription.findMany({ where: { organizationId, phoneNumberId } });
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
subscriptions.map(async (subscription) => {
|
subscriptions.map(async (subscription) => {
|
||||||
const webPushSubscription: PushSubscription = {
|
const webPushSubscription: PushSubscription = {
|
||||||
|
@ -1,40 +1,46 @@
|
|||||||
import { Queue } from "quirrel/blitz";
|
import { Queue } from "quirrel/blitz";
|
||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
|
|
||||||
import db from "../../../../db";
|
import db, { MessageStatus } from "../../../../db";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
id: string;
|
id: string;
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
to: string;
|
to: string;
|
||||||
content: string;
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessageQueue = Queue<Payload>(
|
const sendMessageQueue = Queue<Payload>(
|
||||||
"api/queue/send-message",
|
"api/queue/send-message",
|
||||||
async ({ id, customerId, to, content }) => {
|
async ({ id, organizationId, phoneNumberId, to, content }) => {
|
||||||
const [customer, phoneNumber] = await Promise.all([
|
const organization = await db.organization.findFirst({
|
||||||
db.customer.findFirst({ where: { id: customerId } }),
|
where: { id: organizationId },
|
||||||
db.phoneNumber.findFirst({ where: { customerId } }),
|
include: { phoneNumbers: true },
|
||||||
]);
|
});
|
||||||
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
|
const phoneNumber = organization?.phoneNumbers.find((phoneNumber) => phoneNumber.id === phoneNumberId);
|
||||||
|
if (!organization || !organization.twilioAccountSid || !organization.twilioAuthToken || !phoneNumber) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await twilio(customer.accountSid, customer.authToken).messages.create({
|
const message = await twilio(organization.twilioAccountSid, organization.twilioAuthToken).messages.create({
|
||||||
body: content,
|
body: content,
|
||||||
to,
|
to,
|
||||||
from: phoneNumber.phoneNumber,
|
from: phoneNumber.number,
|
||||||
});
|
});
|
||||||
await db.message.update({
|
await db.message.update({
|
||||||
where: { id },
|
where: { organizationId_phoneNumberId_id: { id, organizationId, phoneNumberId } },
|
||||||
data: { twilioSid: message.sid },
|
data: { id: message.sid },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// TODO: handle twilio error
|
// TODO: handle twilio error
|
||||||
console.log(error.code); // 21211
|
console.log(error.code); // 21211
|
||||||
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
|
console.log(error.moreInfo); // https://www.twilio.com/docs/errors/21211
|
||||||
|
await db.message.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: MessageStatus.Error /*errorMessage: "Reason: failed because of"*/ },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -3,31 +3,26 @@ import twilio from "twilio";
|
|||||||
|
|
||||||
import db from "db";
|
import db from "db";
|
||||||
import handler from "./incoming-message";
|
import handler from "./incoming-message";
|
||||||
import notifyIncomingMessageQueue from "../queue/notify-incoming-message";
|
|
||||||
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
import insertIncomingMessageQueue from "../queue/insert-incoming-message";
|
||||||
|
|
||||||
describe("/api/webhook/incoming-message", () => {
|
describe("/api/webhook/incoming-message", () => {
|
||||||
const mockedFindFirstPhoneNumber = db.phoneNumber.findFirst as jest.Mock<
|
const mockedFindManyPhoneNumbers = db.phoneNumber.findMany as jest.Mock<ReturnType<typeof db.phoneNumber.findMany>>;
|
||||||
ReturnType<typeof db.phoneNumber.findFirst>
|
|
||||||
>;
|
|
||||||
const mockedFindFirstCustomer = db.customer.findFirst as jest.Mock<ReturnType<typeof db.customer.findFirst>>;
|
|
||||||
const mockedEnqueueNotifyIncomingMessage = notifyIncomingMessageQueue.enqueue as jest.Mock<
|
|
||||||
ReturnType<typeof notifyIncomingMessageQueue.enqueue>
|
|
||||||
>;
|
|
||||||
const mockedEnqueueInsertIncomingMessage = insertIncomingMessageQueue.enqueue as jest.Mock<
|
const mockedEnqueueInsertIncomingMessage = insertIncomingMessageQueue.enqueue as jest.Mock<
|
||||||
ReturnType<typeof insertIncomingMessageQueue.enqueue>
|
ReturnType<typeof insertIncomingMessageQueue.enqueue>
|
||||||
>;
|
>;
|
||||||
const mockedValidateRequest = twilio.validateRequest as jest.Mock<ReturnType<typeof twilio.validateRequest>>;
|
const mockedValidateRequest = twilio.validateRequest as jest.Mock<ReturnType<typeof twilio.validateRequest>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockedFindFirstPhoneNumber.mockResolvedValue({ phoneNumber: "+33757592025" } as any);
|
mockedFindManyPhoneNumbers.mockResolvedValue([
|
||||||
mockedFindFirstCustomer.mockResolvedValue({ id: "9292", authToken: "twi" } as any);
|
{
|
||||||
|
id: "9292",
|
||||||
|
organization: { id: "2929", twilioAuthToken: "twi" },
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockedFindFirstPhoneNumber.mockReset();
|
mockedFindManyPhoneNumbers.mockReset();
|
||||||
mockedFindFirstCustomer.mockReset();
|
|
||||||
mockedEnqueueNotifyIncomingMessage.mockReset();
|
|
||||||
mockedEnqueueInsertIncomingMessage.mockReset();
|
mockedEnqueueInsertIncomingMessage.mockReset();
|
||||||
mockedValidateRequest.mockReset();
|
mockedValidateRequest.mockReset();
|
||||||
});
|
});
|
||||||
@ -50,16 +45,15 @@ describe("/api/webhook/incoming-message", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.headers.get("content-type")).toBe("text/html");
|
expect(res.headers.get("content-type")).toBe("text/html");
|
||||||
[mockedEnqueueNotifyIncomingMessage, mockedEnqueueNotifyIncomingMessage].forEach((enqueue) => {
|
expect(mockedEnqueueInsertIncomingMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
expect(mockedEnqueueInsertIncomingMessage).toHaveBeenCalledWith(
|
||||||
expect(enqueue).toHaveBeenCalledWith(
|
|
||||||
{
|
{
|
||||||
messageSid: "SM157246f02006b80953e8c753fb68fad6",
|
messageSid: "SM157246f02006b80953e8c753fb68fad6",
|
||||||
customerId: "9292",
|
phoneNumberId: "9292",
|
||||||
|
organizationId: "2929",
|
||||||
},
|
},
|
||||||
{ id: "notify-SM157246f02006b80953e8c753fb68fad6" },
|
{ id: "insert-SM157246f02006b80953e8c753fb68fad6-2929-9292" },
|
||||||
);
|
);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -107,11 +101,7 @@ describe("/api/webhook/incoming-message", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
jest.mock("db", () => ({
|
jest.mock("db", () => ({
|
||||||
phoneNumber: { findFirst: jest.fn() },
|
phoneNumber: { findMany: jest.fn() },
|
||||||
customer: { findFirst: jest.fn() },
|
|
||||||
}));
|
|
||||||
jest.mock("../queue/notify-incoming-message", () => ({
|
|
||||||
enqueue: jest.fn(),
|
|
||||||
}));
|
}));
|
||||||
jest.mock("../queue/insert-incoming-message", () => ({
|
jest.mock("../queue/insert-incoming-message", () => ({
|
||||||
enqueue: jest.fn(),
|
enqueue: jest.fn(),
|
||||||
|
@ -40,26 +40,25 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
|
|||||||
|
|
||||||
const body: Body = req.body;
|
const body: Body = req.body;
|
||||||
try {
|
try {
|
||||||
const customerPhoneNumber = await db.phoneNumber.findFirst({
|
const phoneNumbers = await db.phoneNumber.findMany({
|
||||||
where: { phoneNumber: body.To },
|
where: { number: body.To },
|
||||||
|
include: { organization: true },
|
||||||
});
|
});
|
||||||
if (!customerPhoneNumber) {
|
if (phoneNumbers.length === 0) {
|
||||||
// phone number is not registered by any of our customer
|
// phone number is not registered by any organization
|
||||||
res.status(500).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await db.customer.findFirst({
|
|
||||||
where: { id: customerPhoneNumber.customerId },
|
|
||||||
});
|
|
||||||
if (!customer || !customer.authToken) {
|
|
||||||
res.status(500).end();
|
res.status(500).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`;
|
const url = `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`;
|
||||||
const isRequestValid = twilio.validateRequest(customer.authToken, twilioSignature, url, req.body);
|
const phoneNumber = phoneNumbers.find((phoneNumber) => {
|
||||||
if (!isRequestValid) {
|
// if multiple organizations have the same number
|
||||||
|
// find the organization currently using that phone number
|
||||||
|
// maybe we shouldn't let multiple organizations use the same phone number
|
||||||
|
const authToken = phoneNumber.organization.twilioAuthToken ?? "";
|
||||||
|
return twilio.validateRequest(authToken, twilioSignature, url, req.body);
|
||||||
|
});
|
||||||
|
if (!phoneNumber) {
|
||||||
const statusCode = 400;
|
const statusCode = 400;
|
||||||
const apiError: ApiError = {
|
const apiError: ApiError = {
|
||||||
statusCode,
|
statusCode,
|
||||||
@ -72,23 +71,16 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageSid = body.MessageSid;
|
const messageSid = body.MessageSid;
|
||||||
const customerId = customer.id;
|
const organizationId = phoneNumber.organization.id;
|
||||||
await Promise.all([
|
const phoneNumberId = phoneNumber.id;
|
||||||
notifyIncomingMessageQueue.enqueue(
|
await insertIncomingMessageQueue.enqueue(
|
||||||
{
|
{
|
||||||
messageSid,
|
messageSid,
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
},
|
},
|
||||||
{ id: `notify-${messageSid}` },
|
{ id: `insert-${messageSid}-${organizationId}-${phoneNumberId}` },
|
||||||
),
|
);
|
||||||
insertIncomingMessageQueue.enqueue(
|
|
||||||
{
|
|
||||||
messageSid,
|
|
||||||
customerId,
|
|
||||||
},
|
|
||||||
{ id: `insert-${messageSid}` },
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.setHeader("content-type", "text/html");
|
res.setHeader("content-type", "text/html");
|
||||||
res.status(200).send("<Response></Response>");
|
res.status(200).send("<Response></Response>");
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
|
import type { FunctionComponent } from "react";
|
||||||
|
import { useMutation, useQuery } from "blitz";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faPaperPlane } from "@fortawesome/pro-regular-svg-icons";
|
import { faPaperPlane } from "@fortawesome/pro-regular-svg-icons";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useMutation, useQuery } from "blitz";
|
|
||||||
|
|
||||||
import sendMessage from "../mutations/send-message";
|
import sendMessage from "../mutations/send-message";
|
||||||
import { Direction, Message, MessageStatus } from "../../../db";
|
import { Direction, Message, MessageStatus } from "../../../db";
|
||||||
import getConversationsQuery from "../queries/get-conversations";
|
import getConversationsQuery from "../queries/get-conversations";
|
||||||
import useCurrentCustomer from "../../core/hooks/use-current-customer";
|
import useCurrentUser from "../../core/hooks/use-current-user";
|
||||||
import useCustomerPhoneNumber from "../../core/hooks/use-customer-phone-number";
|
import useCurrentPhoneNumber from "../../core/hooks/use-current-phone-number";
|
||||||
import { FunctionComponent } from "react";
|
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
content: string;
|
content: string;
|
||||||
@ -20,8 +20,8 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
||||||
const { customer } = useCurrentCustomer();
|
const { organization } = useCurrentUser();
|
||||||
const phoneNumber = useCustomerPhoneNumber();
|
const phoneNumber = useCurrentPhoneNumber();
|
||||||
const sendMessageMutation = useMutation(sendMessage)[0];
|
const sendMessageMutation = useMutation(sendMessage)[0];
|
||||||
const { setQueryData: setConversationsQueryData, refetch: refetchConversations } = useQuery(
|
const { setQueryData: setConversationsQueryData, refetch: refetchConversations } = useQuery(
|
||||||
getConversationsQuery,
|
getConversationsQuery,
|
||||||
@ -45,9 +45,9 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
|||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id,
|
id,
|
||||||
customerId: customer!.id,
|
organizationId: organization!.id,
|
||||||
twilioSid: id,
|
phoneNumberId: phoneNumber!.id,
|
||||||
from: phoneNumber!.phoneNumber,
|
from: phoneNumber!.number,
|
||||||
to: recipient,
|
to: recipient,
|
||||||
content: content,
|
content: content,
|
||||||
direction: Direction.Outbound,
|
direction: Direction.Outbound,
|
||||||
@ -63,7 +63,12 @@ const NewMessageArea: FunctionComponent<Props> = ({ recipient, onSend }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextConversations[recipient] = [...nextConversations[recipient]!, message];
|
nextConversations[recipient] = [...nextConversations[recipient]!, message];
|
||||||
return nextConversations;
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(nextConversations).sort(
|
||||||
|
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ refetch: false },
|
{ refetch: false },
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { resolver } from "blitz";
|
import { NotFoundError, resolver } from "blitz";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
|
|
||||||
import db, { Direction, MessageStatus } from "../../../db";
|
import db, { Direction, MessageStatus } from "../../../db";
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
|
||||||
import getCustomerPhoneNumber from "../../phone-numbers/queries/get-customer-phone-number";
|
|
||||||
import { encrypt } from "../../../db/_encryption";
|
import { encrypt } from "../../../db/_encryption";
|
||||||
import sendMessageQueue from "../../messages/api/queue/send-message";
|
import sendMessageQueue from "../../messages/api/queue/send-message";
|
||||||
import appLogger from "../../../integrations/logger";
|
import appLogger from "../../../integrations/logger";
|
||||||
@ -17,32 +15,40 @@ const Body = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ content, to }, context) => {
|
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ content, to }, context) => {
|
||||||
const customer = await getCurrentCustomer(null, context);
|
const organizationId = context.session.orgId;
|
||||||
if (!customer || !customer.accountSid || !customer.authToken) {
|
const organization = await db.organization.findFirst({
|
||||||
|
where: { id: organizationId },
|
||||||
|
include: { phoneNumbers: true },
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await twilio(customer.accountSid, customer.authToken).lookups.v1.phoneNumbers(to).fetch();
|
await twilio(organization.twilioAccountSid, organization.twilioAuthToken).lookups.v1.phoneNumbers(to).fetch();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerId = customer.id;
|
const phoneNumber = organization.phoneNumbers[0];
|
||||||
const customerPhoneNumber = await getCustomerPhoneNumber({ customerId }, context);
|
if (!phoneNumber) {
|
||||||
if (!customerPhoneNumber) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phoneNumberId = phoneNumber.id;
|
||||||
const message = await db.message.create({
|
const message = await db.message.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
to,
|
to,
|
||||||
from: customerPhoneNumber.phoneNumber,
|
from: phoneNumber.number,
|
||||||
direction: Direction.Outbound,
|
direction: Direction.Outbound,
|
||||||
status: MessageStatus.Queued,
|
status: MessageStatus.Queued,
|
||||||
content: encrypt(content, customer.encryptionKey),
|
content: encrypt(content, organization.encryptionKey),
|
||||||
sentAt: new Date(),
|
sentAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -50,12 +56,13 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
|
|||||||
await sendMessageQueue.enqueue(
|
await sendMessageQueue.enqueue(
|
||||||
{
|
{
|
||||||
id: message.id,
|
id: message.id,
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
to,
|
to,
|
||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `insert-${message.id}`,
|
id: `insert-${message.id}-${organizationId}-${phoneNumberId}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -3,15 +3,14 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import db, { Prisma } from "../../../db";
|
import db, { Prisma } from "../../../db";
|
||||||
import { decrypt } from "../../../db/_encryption";
|
import { decrypt } from "../../../db/_encryption";
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
|
||||||
|
|
||||||
const GetConversations = z.object({
|
const GetConversations = z.object({
|
||||||
recipient: z.string(),
|
recipient: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(), async ({ recipient }, context) => {
|
export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(), async ({ recipient }, context) => {
|
||||||
const customer = await getCurrentCustomer(null, context);
|
const organization = await db.organization.findFirst({ where: { id: context.session.orgId } });
|
||||||
if (!customer) {
|
if (!organization) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +22,7 @@ export default resolver.pipe(resolver.zod(GetConversations), resolver.authorize(
|
|||||||
return conversation.map((message) => {
|
return conversation.map((message) => {
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
content: decrypt(message.content, customer.encryptionKey),
|
content: decrypt(message.content, organization.encryptionKey),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,17 +1,27 @@
|
|||||||
import { resolver, NotFoundError } from "blitz";
|
import { resolver, NotFoundError } from "blitz";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import db, { Direction, Message, Prisma } from "../../../db";
|
import db, { Direction, Message, Prisma } from "../../../db";
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
|
||||||
import { decrypt } from "../../../db/_encryption";
|
import { decrypt } from "../../../db/_encryption";
|
||||||
|
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
|
||||||
|
|
||||||
export default resolver.pipe(resolver.authorize(), async (_ = null, context) => {
|
export default resolver.pipe(
|
||||||
const customer = await getCurrentCustomer(null, context);
|
resolver.zod(z.object({ organizationId: z.string().optional() })),
|
||||||
if (!customer) {
|
resolver.authorize(),
|
||||||
|
setDefaultOrganizationId,
|
||||||
|
enforceSuperAdminIfNotCurrentOrganization,
|
||||||
|
async ({ organizationId }) => {
|
||||||
|
const organization = await db.organization.findFirst({
|
||||||
|
where: { id: organizationId },
|
||||||
|
include: { phoneNumbers: true },
|
||||||
|
});
|
||||||
|
if (!organization) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const phoneNumberId = organization.phoneNumbers[0]!.id;
|
||||||
const messages = await db.message.findMany({
|
const messages = await db.message.findMany({
|
||||||
where: { customerId: customer.id },
|
where: { organizationId, phoneNumberId },
|
||||||
orderBy: { sentAt: Prisma.SortOrder.asc },
|
orderBy: { sentAt: Prisma.SortOrder.asc },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -30,7 +40,7 @@ export default resolver.pipe(resolver.authorize(), async (_ = null, context) =>
|
|||||||
|
|
||||||
conversations[recipient]!.push({
|
conversations[recipient]!.push({
|
||||||
...message,
|
...message,
|
||||||
content: decrypt(message.content, customer.encryptionKey),
|
content: decrypt(message.content, organization.encryptionKey),
|
||||||
});
|
});
|
||||||
|
|
||||||
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
|
||||||
@ -42,4 +52,5 @@ export default resolver.pipe(resolver.authorize(), async (_ = null, context) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return conversations;
|
return conversations;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
@ -5,23 +5,31 @@ import twilio from "twilio";
|
|||||||
import db from "../../../../db";
|
import db from "../../../../db";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { serverRuntimeConfig } = getConfig();
|
const { serverRuntimeConfig } = getConfig();
|
||||||
|
|
||||||
const setTwilioWebhooks = Queue<Payload>("api/queue/set-twilio-webhooks", async ({ customerId }) => {
|
const setTwilioWebhooks = Queue<Payload>("api/queue/set-twilio-webhooks", async ({ organizationId, phoneNumberId }) => {
|
||||||
const [customer, phoneNumber] = await Promise.all([
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
db.customer.findFirst({ where: { id: customerId } }),
|
where: { id: phoneNumberId, organizationId },
|
||||||
db.phoneNumber.findFirst({ where: { customerId } }),
|
include: { organization: true },
|
||||||
]);
|
});
|
||||||
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
|
if (!phoneNumber) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const twimlApp = customer.twimlAppSid
|
const organization = phoneNumber.organization;
|
||||||
? await twilio(customer.accountSid, customer.authToken).applications.get(customer.twimlAppSid).fetch()
|
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
: await twilio(customer.accountSid, customer.authToken).applications.create({
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const twimlApp = organization.twimlAppSid
|
||||||
|
? await twilio(organization.twilioAccountSid, organization.twilioAuthToken)
|
||||||
|
.applications.get(organization.twimlAppSid)
|
||||||
|
.fetch()
|
||||||
|
: await twilio(organization.twilioAccountSid, organization.twilioAuthToken).applications.create({
|
||||||
friendlyName: "Shellphone",
|
friendlyName: "Shellphone",
|
||||||
smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`,
|
smsUrl: `https://${serverRuntimeConfig.app.baseUrl}/api/webhook/incoming-message`,
|
||||||
smsMethod: "POST",
|
smsMethod: "POST",
|
||||||
@ -31,11 +39,13 @@ const setTwilioWebhooks = Queue<Payload>("api/queue/set-twilio-webhooks", async
|
|||||||
const twimlAppSid = twimlApp.sid;
|
const twimlAppSid = twimlApp.sid;
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.customer.update({
|
db.organization.update({
|
||||||
where: { id: customerId },
|
where: { id: organizationId },
|
||||||
data: { twimlAppSid },
|
data: { twimlAppSid },
|
||||||
}),
|
}),
|
||||||
twilio(customer.accountSid, customer.authToken).incomingPhoneNumbers.get(phoneNumber.phoneNumberSid).update({
|
twilio(organization.twilioAccountSid, organization.twilioAuthToken)
|
||||||
|
.incomingPhoneNumbers.get(phoneNumber.id)
|
||||||
|
.update({
|
||||||
smsApplicationSid: twimlAppSid,
|
smsApplicationSid: twimlAppSid,
|
||||||
voiceApplicationSid: twimlAppSid,
|
voiceApplicationSid: twimlAppSid,
|
||||||
}),
|
}),
|
||||||
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
import twilio from "twilio";
|
import twilio from "twilio";
|
||||||
|
|
||||||
import db from "../../../db";
|
import db from "../../../db";
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
import getCurrentUser from "../../users/queries/get-current-user";
|
||||||
import fetchMessagesQueue from "../../messages/api/queue/fetch-messages";
|
import fetchMessagesQueue from "../../messages/api/queue/fetch-messages";
|
||||||
import fetchCallsQueue from "../../phone-calls/api/queue/fetch-calls";
|
import fetchCallsQueue from "../../phone-calls/api/queue/fetch-calls";
|
||||||
import setTwilioWebhooks from "../api/queue/set-twilio-webhooks";
|
import setTwilioWebhooks from "../api/queue/set-twilio-webhooks";
|
||||||
@ -13,26 +13,40 @@ const Body = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ phoneNumberSid }, context) => {
|
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ phoneNumberSid }, context) => {
|
||||||
const customer = await getCurrentCustomer(null, context);
|
const user = await getCurrentUser(null, context);
|
||||||
if (!customer || !customer.accountSid || !customer.authToken) {
|
const organization = user?.memberships[0]!.organization;
|
||||||
|
if (!user || !organization || !organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerId = customer.id;
|
const phoneNumbers = await twilio(
|
||||||
const phoneNumbers = await twilio(customer.accountSid, customer.authToken).incomingPhoneNumbers.list();
|
organization.twilioAccountSid,
|
||||||
|
organization.twilioAuthToken,
|
||||||
|
).incomingPhoneNumbers.list();
|
||||||
const phoneNumber = phoneNumbers.find((phoneNumber) => phoneNumber.sid === phoneNumberSid)!;
|
const phoneNumber = phoneNumbers.find((phoneNumber) => phoneNumber.sid === phoneNumberSid)!;
|
||||||
|
const organizationId = organization.id;
|
||||||
await db.phoneNumber.create({
|
await db.phoneNumber.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
organizationId,
|
||||||
phoneNumberSid,
|
id: phoneNumberSid,
|
||||||
phoneNumber: phoneNumber.phoneNumber,
|
number: phoneNumber.phoneNumber,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
context.session.$setPrivateData({ hasCompletedOnboarding: true });
|
context.session.$setPrivateData({ hasCompletedOnboarding: true });
|
||||||
|
|
||||||
|
const phoneNumberId = phoneNumberSid;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchMessagesQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
|
fetchMessagesQueue.enqueue(
|
||||||
fetchCallsQueue.enqueue({ customerId }, { id: `fetch-messages-${customerId}` }),
|
{ organizationId, phoneNumberId },
|
||||||
setTwilioWebhooks.enqueue({ customerId }, { id: `set-twilio-webhooks-${customerId}` }),
|
{ id: `fetch-messages-${organizationId}-${phoneNumberId}` },
|
||||||
|
),
|
||||||
|
fetchCallsQueue.enqueue(
|
||||||
|
{ organizationId, phoneNumberId },
|
||||||
|
{ id: `fetch-messages-${organizationId}-${phoneNumberId}` },
|
||||||
|
),
|
||||||
|
setTwilioWebhooks.enqueue(
|
||||||
|
{ organizationId, phoneNumberId },
|
||||||
|
{ id: `set-twilio-webhooks-${organizationId}-${phoneNumberId}` },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,7 @@ import { resolver } from "blitz";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import db from "../../../db";
|
import db from "../../../db";
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
import getCurrentUser from "../../users/queries/get-current-user";
|
||||||
|
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
twilioAccountSid: z.string(),
|
twilioAccountSid: z.string(),
|
||||||
@ -13,16 +13,17 @@ export default resolver.pipe(
|
|||||||
resolver.zod(Body),
|
resolver.zod(Body),
|
||||||
resolver.authorize(),
|
resolver.authorize(),
|
||||||
async ({ twilioAccountSid, twilioAuthToken }, context) => {
|
async ({ twilioAccountSid, twilioAuthToken }, context) => {
|
||||||
const customer = await getCurrentCustomer(null, context);
|
const user = await getCurrentUser(null, context);
|
||||||
if (!customer) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.customer.update({
|
const organizationId = user.memberships[0]!.id;
|
||||||
where: { id: customer.id },
|
await db.organization.update({
|
||||||
|
where: { id: organizationId },
|
||||||
data: {
|
data: {
|
||||||
accountSid: twilioAccountSid,
|
twilioAccountSid: twilioAccountSid,
|
||||||
authToken: twilioAuthToken,
|
twilioAuthToken: twilioAuthToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -2,11 +2,11 @@ import type { BlitzPage, GetServerSideProps } from "blitz";
|
|||||||
import { getSession, Routes } from "blitz";
|
import { getSession, Routes } from "blitz";
|
||||||
|
|
||||||
import OnboardingLayout from "../../components/onboarding-layout";
|
import OnboardingLayout from "../../components/onboarding-layout";
|
||||||
import useCurrentCustomer from "../../../core/hooks/use-current-customer";
|
import useCurrentUser from "../../../core/hooks/use-current-user";
|
||||||
import db from "../../../../db";
|
import db from "../../../../db";
|
||||||
|
|
||||||
const StepOne: BlitzPage = () => {
|
const StepOne: BlitzPage = () => {
|
||||||
useCurrentCustomer(); // preload for step two
|
useCurrentUser(); // preload for step two
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4 items-center">
|
<div className="flex flex-col space-y-4 items-center">
|
||||||
@ -35,7 +35,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId: session.userId } });
|
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
|
||||||
if (phoneNumber) {
|
if (phoneNumber) {
|
||||||
await session.$setPublicData({ hasCompletedOnboarding: true });
|
await session.$setPublicData({ hasCompletedOnboarding: true });
|
||||||
return {
|
return {
|
||||||
|
@ -100,7 +100,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId: session.userId } });
|
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
|
||||||
if (phoneNumber) {
|
if (phoneNumber) {
|
||||||
await session.$setPublicData({ hasCompletedOnboarding: true });
|
await session.$setPublicData({ hasCompletedOnboarding: true });
|
||||||
return {
|
return {
|
||||||
@ -111,8 +111,8 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = await db.customer.findFirst({ where: { id: session.userId } });
|
const organization = await db.organization.findFirst({ where: { id: session.orgId } });
|
||||||
if (!customer) {
|
if (!organization) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: Routes.StepOne().pathname,
|
destination: Routes.StepOne().pathname,
|
||||||
@ -121,7 +121,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!customer.accountSid || !customer.authToken) {
|
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: Routes.StepTwo().pathname,
|
destination: Routes.StepTwo().pathname,
|
||||||
@ -130,7 +130,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomingPhoneNumbers = await twilio(customer.accountSid, customer.authToken).incomingPhoneNumbers.list();
|
const incomingPhoneNumbers = await twilio(
|
||||||
|
organization.twilioAccountSid,
|
||||||
|
organization.twilioAuthToken,
|
||||||
|
).incomingPhoneNumbers.list();
|
||||||
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
|
const phoneNumbers = incomingPhoneNumbers.map(({ phoneNumber, sid }) => ({ phoneNumber, sid }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -11,7 +11,7 @@ import db from "db";
|
|||||||
import setTwilioApiFields from "../../mutations/set-twilio-api-fields";
|
import setTwilioApiFields from "../../mutations/set-twilio-api-fields";
|
||||||
import OnboardingLayout from "../../components/onboarding-layout";
|
import OnboardingLayout from "../../components/onboarding-layout";
|
||||||
import HelpModal from "../../components/help-modal";
|
import HelpModal from "../../components/help-modal";
|
||||||
import useCurrentCustomer from "../../../core/hooks/use-current-customer";
|
import useCurrentUser from "../../../core/hooks/use-current-user";
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
twilioAccountSid: string;
|
twilioAccountSid: string;
|
||||||
@ -26,14 +26,14 @@ const StepTwo: BlitzPage = () => {
|
|||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm<Form>();
|
} = useForm<Form>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { customer } = useCurrentCustomer();
|
const { organization } = useCurrentUser();
|
||||||
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields);
|
const [setTwilioApiFieldsMutation] = useMutation(setTwilioApiFields);
|
||||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("twilioAuthToken", customer?.authToken ?? "");
|
setValue("twilioAuthToken", organization?.twilioAuthToken ?? "");
|
||||||
setValue("twilioAccountSid", customer?.accountSid ?? "");
|
setValue("twilioAccountSid", organization?.twilioAccountSid ?? "");
|
||||||
}, [setValue, customer?.authToken, customer?.accountSid]);
|
}, [setValue, organization?.twilioAuthToken, organization?.twilioAccountSid]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
const onSubmit = handleSubmit(async ({ twilioAccountSid, twilioAuthToken }) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
@ -105,9 +105,9 @@ StepTwo.getLayout = (page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const StepTwoLayout: FunctionComponent = ({ children }) => {
|
const StepTwoLayout: FunctionComponent = ({ children }) => {
|
||||||
const { customer } = useCurrentCustomer();
|
const { organization } = useCurrentUser();
|
||||||
const initialAuthToken = customer?.authToken ?? "";
|
const initialAuthToken = organization?.twilioAuthToken ?? "";
|
||||||
const initialAccountSid = customer?.accountSid ?? "";
|
const initialAccountSid = organization?.twilioAccountSid ?? "";
|
||||||
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0;
|
const hasTwilioCredentials = initialAccountSid.length > 0 && initialAuthToken.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -135,7 +135,7 @@ export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const phoneNumber = await db.phoneNumber.findFirst({ where: { customerId: session.userId } });
|
const phoneNumber = await db.phoneNumber.findFirst({ where: { organizationId: session.orgId } });
|
||||||
if (phoneNumber) {
|
if (phoneNumber) {
|
||||||
await session.$setPublicData({ hasCompletedOnboarding: true });
|
await session.$setPublicData({ hasCompletedOnboarding: true });
|
||||||
return {
|
return {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
import { GlobalRole } from "db";
|
||||||
import { render } from "../../test/utils";
|
import { render } from "../../test/utils";
|
||||||
import Home from "./index";
|
import Home from "./index";
|
||||||
import useCurrentCustomer from "../core/hooks/use-current-customer";
|
import useCurrentUser from "../core/hooks/use-current-user";
|
||||||
|
|
||||||
jest.mock("../core/hooks/use-current-customer");
|
jest.mock("../core/hooks/use-current-user");
|
||||||
const mockUseCurrentCustomer = useCurrentCustomer as jest.MockedFunction<typeof useCurrentCustomer>;
|
const mockUseCurrentUser = useCurrentUser as jest.MockedFunction<typeof useCurrentUser>;
|
||||||
|
|
||||||
test.skip("renders blitz documentation link", () => {
|
test.skip("renders blitz documentation link", () => {
|
||||||
// This is an example of how to ensure a specific item is in the document
|
// This is an example of how to ensure a specific item is in the document
|
||||||
@ -11,16 +12,14 @@ test.skip("renders blitz documentation link", () => {
|
|||||||
// when you remove the the default content from the page
|
// when you remove the the default content from the page
|
||||||
|
|
||||||
// This is an example on how to mock api hooks when testing
|
// This is an example on how to mock api hooks when testing
|
||||||
mockUseCurrentCustomer.mockReturnValue({
|
mockUseCurrentUser.mockReturnValue({
|
||||||
customer: {
|
organization: undefined,
|
||||||
|
user: {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
encryptionKey: "",
|
name: "name",
|
||||||
accountSid: null,
|
email: "email@test.com",
|
||||||
authToken: null,
|
role: GlobalRole.CUSTOMER,
|
||||||
twimlAppSid: null,
|
memberships: [],
|
||||||
paddleCustomerId: null,
|
|
||||||
paddleSubscriptionId: null,
|
|
||||||
user: {} as any,
|
|
||||||
},
|
},
|
||||||
hasFilledTwilioCredentials: false,
|
hasFilledTwilioCredentials: false,
|
||||||
hasCompletedOnboarding: undefined,
|
hasCompletedOnboarding: undefined,
|
||||||
|
@ -4,7 +4,7 @@ import { Link, useMutation, Routes } from "blitz";
|
|||||||
|
|
||||||
import BaseLayout from "../core/layouts/base-layout";
|
import BaseLayout from "../core/layouts/base-layout";
|
||||||
import logout from "../auth/mutations/logout";
|
import logout from "../auth/mutations/logout";
|
||||||
import useCurrentCustomer from "../core/hooks/use-current-customer";
|
import useCurrentUser from "../core/hooks/use-current-user";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This file is just for a pleasant getting started page for your new app.
|
* This file is just for a pleasant getting started page for your new app.
|
||||||
@ -12,10 +12,10 @@ import useCurrentCustomer from "../core/hooks/use-current-customer";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const UserInfo = () => {
|
const UserInfo = () => {
|
||||||
const { customer } = useCurrentCustomer();
|
const { user } = useCurrentUser();
|
||||||
const [logoutMutation] = useMutation(logout);
|
const [logoutMutation] = useMutation(logout);
|
||||||
|
|
||||||
if (customer) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -27,9 +27,9 @@ const UserInfo = () => {
|
|||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
User id: <code>{customer.id}</code>
|
User id: <code>{user.id}</code>
|
||||||
<br />
|
<br />
|
||||||
User role: <code>{customer.encryptionKey}</code>
|
User role: <code>{user.role}</code>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -5,35 +5,42 @@ import db from "../../../../db";
|
|||||||
import insertCallsQueue from "./insert-calls";
|
import insertCallsQueue from "./insert-calls";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchCallsQueue = Queue<Payload>("api/queue/fetch-calls", async ({ customerId }) => {
|
const fetchCallsQueue = Queue<Payload>("api/queue/fetch-calls", async ({ organizationId, phoneNumberId }) => {
|
||||||
const [customer, phoneNumber] = await Promise.all([
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
db.customer.findFirst({ where: { id: customerId } }),
|
where: { id: phoneNumberId, organizationId },
|
||||||
db.phoneNumber.findFirst({ where: { customerId } }),
|
include: { organization: true },
|
||||||
]);
|
});
|
||||||
if (!customer || !customer.accountSid || !customer.authToken || !phoneNumber) {
|
if (!phoneNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organization = phoneNumber.organization;
|
||||||
|
if (!organization.twilioAccountSid || !organization.twilioAuthToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [callsSent, callsReceived] = await Promise.all([
|
const [callsSent, callsReceived] = await Promise.all([
|
||||||
twilio(customer.accountSid, customer.authToken).calls.list({
|
twilio(organization.twilioAccountSid, organization.twilioAuthToken).calls.list({
|
||||||
from: phoneNumber.phoneNumber,
|
from: phoneNumber.number,
|
||||||
}),
|
}),
|
||||||
twilio(customer.accountSid, customer.authToken).calls.list({
|
twilio(organization.twilioAccountSid, organization.twilioAuthToken).calls.list({
|
||||||
to: phoneNumber.phoneNumber,
|
to: phoneNumber.number,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const calls = [...callsSent, ...callsReceived].sort((a, b) => a.dateCreated.getTime() - b.dateCreated.getTime());
|
const calls = [...callsSent, ...callsReceived].sort((a, b) => a.dateCreated.getTime() - b.dateCreated.getTime());
|
||||||
|
|
||||||
await insertCallsQueue.enqueue(
|
await insertCallsQueue.enqueue(
|
||||||
{
|
{
|
||||||
customerId,
|
organizationId,
|
||||||
|
phoneNumberId,
|
||||||
calls,
|
calls,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `insert-calls-${customerId}`,
|
id: `insert-calls-${organizationId}-${phoneNumberId}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -4,15 +4,25 @@ import type { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
|||||||
import db, { Direction, CallStatus } from "../../../../db";
|
import db, { Direction, CallStatus } from "../../../../db";
|
||||||
|
|
||||||
type Payload = {
|
type Payload = {
|
||||||
customerId: string;
|
organizationId: string;
|
||||||
|
phoneNumberId: string;
|
||||||
calls: CallInstance[];
|
calls: CallInstance[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertCallsQueue = Queue<Payload>("api/queue/insert-calls", async ({ calls, customerId }) => {
|
const insertCallsQueue = Queue<Payload>("api/queue/insert-calls", async ({ calls, organizationId, phoneNumberId }) => {
|
||||||
|
const phoneNumber = await db.phoneNumber.findFirst({
|
||||||
|
where: { id: phoneNumberId, organizationId },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const phoneCalls = calls
|
const phoneCalls = calls
|
||||||
.map((call) => ({
|
.map((call) => ({
|
||||||
customerId,
|
organizationId,
|
||||||
twilioSid: call.sid,
|
phoneNumberId,
|
||||||
|
id: call.sid,
|
||||||
from: call.from,
|
from: call.from,
|
||||||
to: call.to,
|
to: call.to,
|
||||||
direction: translateDirection(call.direction),
|
direction: translateDirection(call.direction),
|
||||||
|
@ -2,7 +2,7 @@ import { Direction } from "../../../db";
|
|||||||
import usePhoneCalls from "../hooks/use-phone-calls";
|
import usePhoneCalls from "../hooks/use-phone-calls";
|
||||||
|
|
||||||
export default function PhoneCallsList() {
|
export default function PhoneCallsList() {
|
||||||
const phoneCalls = usePhoneCalls();
|
const phoneCalls = usePhoneCalls()[0];
|
||||||
|
|
||||||
if (phoneCalls.length === 0) {
|
if (phoneCalls.length === 0) {
|
||||||
return <div>empty state</div>;
|
return <div>empty state</div>;
|
||||||
@ -13,7 +13,7 @@ export default function PhoneCallsList() {
|
|||||||
{phoneCalls.map((phoneCall) => {
|
{phoneCalls.map((phoneCall) => {
|
||||||
const recipient = Direction.Outbound ? phoneCall.to : phoneCall.from;
|
const recipient = Direction.Outbound ? phoneCall.to : phoneCall.from;
|
||||||
return (
|
return (
|
||||||
<li key={phoneCall.twilioSid} className="flex flex-row justify-between py-2">
|
<li key={phoneCall.id} className="flex flex-row justify-between py-2">
|
||||||
<div>{recipient}</div>
|
<div>{recipient}</div>
|
||||||
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
<div>{new Date(phoneCall.createdAt).toLocaleString("fr-FR")}</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import { NotFoundError, useQuery } from "blitz";
|
import { NotFoundError, useQuery } from "blitz";
|
||||||
|
|
||||||
import useCurrentCustomer from "../../core/hooks/use-current-customer";
|
import useCurrentPhoneNumber from "../..//core/hooks/use-current-phone-number";
|
||||||
import getPhoneCalls from "../queries/get-phone-calls";
|
import getPhoneCalls from "../queries/get-phone-calls";
|
||||||
|
|
||||||
export default function usePhoneCalls() {
|
export default function usePhoneCalls() {
|
||||||
const { customer } = useCurrentCustomer();
|
const phoneNumber = useCurrentPhoneNumber();
|
||||||
if (!customer) {
|
if (!phoneNumber) {
|
||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { phoneCalls } = useQuery(getPhoneCalls, { customerId: customer.id })[0];
|
return useQuery(getPhoneCalls, { phoneNumberId: phoneNumber.id });
|
||||||
|
|
||||||
return phoneCalls;
|
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,16 @@
|
|||||||
import { paginate, resolver } from "blitz";
|
import { resolver } from "blitz";
|
||||||
import db, { Prisma, Customer } from "db";
|
import { z } from "zod";
|
||||||
|
import db, { Prisma } from "db";
|
||||||
|
|
||||||
interface GetPhoneCallsInput extends Pick<Prisma.PhoneCallFindManyArgs, "where" | "orderBy" | "skip" | "take"> {
|
const Body = z.object({
|
||||||
customerId: Customer["id"];
|
phoneNumberId: z.string(),
|
||||||
}
|
});
|
||||||
|
|
||||||
export default resolver.pipe(
|
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ phoneNumberId }, context) => {
|
||||||
resolver.authorize(),
|
const organizationId = context.session.orgId;
|
||||||
async ({ where, orderBy, skip = 0, take = 100 }: GetPhoneCallsInput) => {
|
|
||||||
// TODO: in multi-tenant app, you must add validation to ensure correct tenant
|
return db.phoneCall.findMany({
|
||||||
const {
|
where: { organizationId, phoneNumberId },
|
||||||
items: phoneCalls,
|
orderBy: { createdAt: Prisma.SortOrder.asc },
|
||||||
hasMore,
|
|
||||||
nextPage,
|
|
||||||
count,
|
|
||||||
} = await paginate({
|
|
||||||
skip,
|
|
||||||
take,
|
|
||||||
count: () => db.phoneCall.count({ where }),
|
|
||||||
query: (paginateArgs) => db.phoneCall.findMany({ ...paginateArgs, where, orderBy }),
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return {
|
|
||||||
phoneCalls,
|
|
||||||
nextPage,
|
|
||||||
hasMore,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import { NotFoundError, resolver } from "blitz";
|
|
||||||
|
|
||||||
import db from "db";
|
|
||||||
import getCurrentCustomer from "../../customers/queries/get-current-customer";
|
|
||||||
|
|
||||||
export default resolver.pipe(resolver.authorize(), async (_ = null, context) => {
|
|
||||||
const customer = await getCurrentCustomer(null, context);
|
|
||||||
if (!customer) {
|
|
||||||
throw new NotFoundError();
|
|
||||||
}
|
|
||||||
|
|
||||||
return db.phoneNumber.findFirst({
|
|
||||||
where: { customerId: customer.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
phoneNumber: true,
|
|
||||||
phoneNumberSid: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
21
app/phone-numbers/queries/get-current-phone-number.ts
Normal file
21
app/phone-numbers/queries/get-current-phone-number.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { resolver } from "blitz";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import db from "db";
|
||||||
|
import { enforceSuperAdminIfNotCurrentOrganization, setDefaultOrganizationId } from "../../core/utils";
|
||||||
|
|
||||||
|
export default resolver.pipe(
|
||||||
|
resolver.zod(z.object({ organizationId: z.string().optional() })),
|
||||||
|
resolver.authorize(),
|
||||||
|
setDefaultOrganizationId,
|
||||||
|
enforceSuperAdminIfNotCurrentOrganization,
|
||||||
|
async ({ organizationId }) => {
|
||||||
|
return db.phoneNumber.findFirst({
|
||||||
|
where: { organizationId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
@ -1,19 +0,0 @@
|
|||||||
import { resolver } from "blitz";
|
|
||||||
import db from "db";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const GetCustomerPhoneNumber = z.object({
|
|
||||||
// This accepts type of undefined, but is required at runtime
|
|
||||||
customerId: z.string().optional().refine(Boolean, "Required"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(GetCustomerPhoneNumber), async ({ customerId }) =>
|
|
||||||
db.phoneNumber.findFirst({
|
|
||||||
where: { customerId },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
phoneNumber: true,
|
|
||||||
phoneNumberSid: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
@ -4,10 +4,8 @@ import clsx from "clsx";
|
|||||||
import Button from "./button";
|
import Button from "./button";
|
||||||
import SettingsSection from "./settings-section";
|
import SettingsSection from "./settings-section";
|
||||||
import Modal, { ModalTitle } from "./modal";
|
import Modal, { ModalTitle } from "./modal";
|
||||||
import useCurrentCustomer from "../../core/hooks/use-current-customer";
|
|
||||||
|
|
||||||
export default function DangerZone() {
|
export default function DangerZone() {
|
||||||
const customer = useCurrentCustomer();
|
|
||||||
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
const [isDeletingUser, setIsDeletingUser] = useState(false);
|
||||||
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
|
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
|
||||||
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
const modalCancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import Alert from "./alert";
|
import Alert from "./alert";
|
||||||
import Button from "./button";
|
import Button from "./button";
|
||||||
import SettingsSection from "./settings-section";
|
import SettingsSection from "./settings-section";
|
||||||
import useCurrentCustomer from "../../core/hooks/use-current-customer";
|
import useCurrentUser from "../../core/hooks/use-current-user";
|
||||||
|
|
||||||
import appLogger from "../../../integrations/logger";
|
import appLogger from "../../../integrations/logger";
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ type Form = {
|
|||||||
const logger = appLogger.child({ module: "profile-settings" });
|
const logger = appLogger.child({ module: "profile-settings" });
|
||||||
|
|
||||||
const ProfileInformations: FunctionComponent = () => {
|
const ProfileInformations: FunctionComponent = () => {
|
||||||
const { customer } = useCurrentCustomer();
|
const { user } = useCurrentUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -29,9 +29,9 @@ const ProfileInformations: FunctionComponent = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setValue("name", customer?.user.name ?? "");
|
setValue("name", user?.name ?? "");
|
||||||
setValue("email", customer?.user.email ?? "");
|
setValue("email", user?.email ?? "");
|
||||||
}, [setValue, customer]);
|
}, [setValue, user]);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async ({ name, email }) => {
|
const onSubmit = handleSubmit(async ({ name, email }) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
@ -40,7 +40,7 @@ const ProfileInformations: FunctionComponent = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO
|
// TODO
|
||||||
// await customer.updateUser({ email, data: { name } });
|
// await updateUser({ email, data: { name } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error.response, "error updating user infos");
|
logger.error(error.response, "error updating user infos");
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ import { useForm } from "react-hook-form";
|
|||||||
import Alert from "./alert";
|
import Alert from "./alert";
|
||||||
import Button from "./button";
|
import Button from "./button";
|
||||||
import SettingsSection from "./settings-section";
|
import SettingsSection from "./settings-section";
|
||||||
import useCurrentCustomer from "../../core/hooks/use-current-customer";
|
|
||||||
|
|
||||||
import appLogger from "../../../integrations/logger";
|
import appLogger from "../../../integrations/logger";
|
||||||
|
|
||||||
@ -18,7 +17,6 @@ type Form = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UpdatePassword: FunctionComponent = () => {
|
const UpdatePassword: FunctionComponent = () => {
|
||||||
const customer = useCurrentCustomer();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
32
app/users/queries/get-current-user.ts
Normal file
32
app/users/queries/get-current-user.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Ctx } from "blitz";
|
||||||
|
|
||||||
|
import db from "db";
|
||||||
|
|
||||||
|
export default async function getCurrentUser(_ = null, { session }: Ctx) {
|
||||||
|
if (!session.userId) return null;
|
||||||
|
|
||||||
|
return db.user.findFirst({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
memberships: {
|
||||||
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
encryptionKey: true,
|
||||||
|
paddleCustomerId: true,
|
||||||
|
paddleSubscriptionId: true,
|
||||||
|
twilioAccountSid: true,
|
||||||
|
twilioAuthToken: true,
|
||||||
|
twimlAppSid: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
148
db/migrations/20210804092849_add_orgs/migration.sql
Normal file
148
db/migrations/20210804092849_add_orgs/migration.sql
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `customerId` on the `Message` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `twilioSid` on the `Message` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customerId` on the `NotificationSubscription` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customerId` on the `PhoneCall` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `twilioSid` on the `PhoneCall` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `customerId` on the `PhoneNumber` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `phoneNumber` on the `PhoneNumber` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `phoneNumberSid` on the `PhoneNumber` table. All the data in the column will be lost.
|
||||||
|
- The `role` column on the `User` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
- You are about to drop the `Customer` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- A unique constraint covering the columns `[phoneNumberId,id]` on the table `Message` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[phoneNumberId,id]` on the table `PhoneCall` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[organizationId,id]` on the table `PhoneNumber` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `phoneNumberId` to the `Message` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `organizationId` to the `NotificationSubscription` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `phoneNumberId` to the `NotificationSubscription` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `phoneNumberId` to the `PhoneCall` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `number` to the `PhoneNumber` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `organizationId` to the `PhoneNumber` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MembershipRole" AS ENUM ('OWNER', 'ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "GlobalRole" AS ENUM ('SUPERADMIN', 'CUSTOMER');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "MessageStatus" ADD VALUE 'Error';
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Customer" DROP CONSTRAINT "Customer_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Message" DROP CONSTRAINT "Message_customerId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationSubscription" DROP CONSTRAINT "NotificationSubscription_customerId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PhoneCall" DROP CONSTRAINT "PhoneCall_customerId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PhoneNumber" DROP CONSTRAINT "PhoneNumber_customerId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Message" DROP COLUMN "customerId",
|
||||||
|
DROP COLUMN "twilioSid",
|
||||||
|
ADD COLUMN "phoneNumberId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationSubscription" DROP COLUMN "customerId",
|
||||||
|
ADD COLUMN "organizationId" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "phoneNumberId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PhoneCall" DROP COLUMN "customerId",
|
||||||
|
DROP COLUMN "twilioSid",
|
||||||
|
ADD COLUMN "phoneNumberId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PhoneNumber" DROP COLUMN "customerId",
|
||||||
|
DROP COLUMN "phoneNumber",
|
||||||
|
DROP COLUMN "phoneNumberSid",
|
||||||
|
ADD COLUMN "number" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "organizationId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" DROP COLUMN "role",
|
||||||
|
ADD COLUMN "role" "GlobalRole" NOT NULL DEFAULT E'CUSTOMER';
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Customer";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TwilioCredentials" (
|
||||||
|
"accountSid" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL,
|
||||||
|
"authToken" TEXT NOT NULL,
|
||||||
|
"twimlAppSid" TEXT,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("accountSid")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Organization" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL,
|
||||||
|
"encryptionKey" TEXT NOT NULL,
|
||||||
|
"paddleCustomerId" TEXT,
|
||||||
|
"paddleSubscriptionId" TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Membership" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"role" "MembershipRole" NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"invitedName" TEXT,
|
||||||
|
"invitedEmail" TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Membership.organizationId_invitedEmail_unique" ON "Membership"("organizationId", "invitedEmail");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Message.phoneNumberId_id_unique" ON "Message"("phoneNumberId", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PhoneCall.phoneNumberId_id_unique" ON "PhoneCall"("phoneNumberId", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PhoneNumber.organizationId_id_unique" ON "PhoneNumber"("organizationId", "id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TwilioCredentials" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Membership" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Membership" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Message" ADD FOREIGN KEY ("phoneNumberId") REFERENCES "PhoneNumber"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhoneCall" ADD FOREIGN KEY ("phoneNumberId") REFERENCES "PhoneNumber"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhoneNumber" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationSubscription" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationSubscription" ADD FOREIGN KEY ("phoneNumberId") REFERENCES "PhoneNumber"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the `TwilioCredentials` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "TwilioCredentials" DROP CONSTRAINT "TwilioCredentials_organizationId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Organization" ADD COLUMN "twilioAccountSid" TEXT,
|
||||||
|
ADD COLUMN "twilioAuthToken" TEXT,
|
||||||
|
ADD COLUMN "twimlAppSid" TEXT;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "TwilioCredentials";
|
36
db/migrations/20210805153229_org_id_wesh/migration.sql
Normal file
36
db/migrations/20210805153229_org_id_wesh/migration.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[organizationId,phoneNumberId,id]` on the table `Message` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[id,twilioAccountSid]` on the table `Organization` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[organizationId,phoneNumberId,id]` on the table `PhoneCall` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `organizationId` to the `Message` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `organizationId` to the `PhoneCall` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Message.phoneNumberId_id_unique";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "PhoneCall.phoneNumberId_id_unique";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Message" ADD COLUMN "organizationId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PhoneCall" ADD COLUMN "organizationId" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Message.organizationId_phoneNumberId_id_unique" ON "Message"("organizationId", "phoneNumberId", "id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Organization.id_twilioAccountSid_unique" ON "Organization"("id", "twilioAccountSid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PhoneCall.organizationId_phoneNumberId_id_unique" ON "PhoneCall"("organizationId", "phoneNumberId", "id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Message" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PhoneCall" ADD FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
116
db/schema.prisma
116
db/schema.prisma
@ -12,6 +12,56 @@ generator client {
|
|||||||
|
|
||||||
// --------------------------------------
|
// --------------------------------------
|
||||||
|
|
||||||
|
model Organization {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now()) @db.Timestamptz
|
||||||
|
updatedAt DateTime @updatedAt @db.Timestamptz
|
||||||
|
encryptionKey String
|
||||||
|
paddleCustomerId String?
|
||||||
|
paddleSubscriptionId String?
|
||||||
|
|
||||||
|
twilioAccountSid String?
|
||||||
|
twilioAuthToken String? // TODO: encrypt it with encryptionKey
|
||||||
|
twimlAppSid String?
|
||||||
|
|
||||||
|
memberships Membership[]
|
||||||
|
phoneNumbers PhoneNumber[]
|
||||||
|
notificationSubscriptions NotificationSubscription[]
|
||||||
|
messages Message[]
|
||||||
|
phoneCalls PhoneCall[]
|
||||||
|
|
||||||
|
@@unique([id, twilioAccountSid])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Membership {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
role MembershipRole
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
userId String?
|
||||||
|
|
||||||
|
// When the user joins, we will clear out the name and email and set the user.
|
||||||
|
invitedName String?
|
||||||
|
invitedEmail String?
|
||||||
|
|
||||||
|
@@unique([organizationId, invitedEmail])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MembershipRole {
|
||||||
|
OWNER
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owners of the SaaS (you) can have a SUPERADMIN role to access all data
|
||||||
|
enum GlobalRole {
|
||||||
|
SUPERADMIN
|
||||||
|
CUSTOMER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz
|
createdAt DateTime @default(now()) @db.Timestamptz
|
||||||
@ -19,11 +69,11 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
email String @unique
|
email String @unique
|
||||||
hashedPassword String?
|
hashedPassword String?
|
||||||
role Role @default(USER)
|
role GlobalRole @default(CUSTOMER)
|
||||||
|
|
||||||
|
memberships Membership[]
|
||||||
tokens Token[]
|
tokens Token[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
customer Customer[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
enum Role {
|
||||||
@ -65,25 +115,6 @@ enum TokenType {
|
|||||||
RESET_PASSWORD
|
RESET_PASSWORD
|
||||||
}
|
}
|
||||||
|
|
||||||
model Customer {
|
|
||||||
id String @id
|
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz
|
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz
|
|
||||||
encryptionKey String
|
|
||||||
accountSid String?
|
|
||||||
authToken String?
|
|
||||||
// TODO: encrypt it with encryptionKey
|
|
||||||
twimlAppSid String?
|
|
||||||
paddleCustomerId String?
|
|
||||||
paddleSubscriptionId String?
|
|
||||||
|
|
||||||
user User @relation(fields: [id], references: [id])
|
|
||||||
messages Message[]
|
|
||||||
phoneCalls PhoneCall[]
|
|
||||||
phoneNumbers PhoneNumber[]
|
|
||||||
notificationSubscriptions NotificationSubscription[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Message {
|
model Message {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
sentAt DateTime @db.Timestamptz
|
sentAt DateTime @db.Timestamptz
|
||||||
@ -92,10 +123,13 @@ model Message {
|
|||||||
to String
|
to String
|
||||||
direction Direction
|
direction Direction
|
||||||
status MessageStatus
|
status MessageStatus
|
||||||
twilioSid String?
|
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
customerId String
|
organizationId String
|
||||||
|
phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id])
|
||||||
|
phoneNumberId String
|
||||||
|
|
||||||
|
@@unique([organizationId, phoneNumberId, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Direction {
|
enum Direction {
|
||||||
@ -117,20 +151,25 @@ enum MessageStatus {
|
|||||||
Read
|
Read
|
||||||
PartiallyDelivered
|
PartiallyDelivered
|
||||||
Canceled
|
Canceled
|
||||||
|
|
||||||
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
model PhoneCall {
|
model PhoneCall {
|
||||||
id String @id @default(uuid())
|
id String @id
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz
|
createdAt DateTime @default(now()) @db.Timestamptz
|
||||||
twilioSid String
|
|
||||||
from String
|
from String
|
||||||
to String
|
to String
|
||||||
status CallStatus
|
status CallStatus
|
||||||
direction Direction
|
direction Direction
|
||||||
duration String
|
duration String
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
customerId String
|
organizationId String
|
||||||
|
phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id])
|
||||||
|
phoneNumberId String
|
||||||
|
|
||||||
|
@@unique([organizationId, phoneNumberId, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CallStatus {
|
enum CallStatus {
|
||||||
@ -145,13 +184,18 @@ enum CallStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model PhoneNumber {
|
model PhoneNumber {
|
||||||
id String @id @default(uuid())
|
id String @id
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz
|
createdAt DateTime @default(now()) @db.Timestamptz
|
||||||
phoneNumberSid String
|
number String
|
||||||
phoneNumber String
|
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id])
|
messages Message[]
|
||||||
customerId String
|
phoneCalls PhoneCall[]
|
||||||
|
notificationSubscriptions NotificationSubscription[]
|
||||||
|
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
|
organizationId String
|
||||||
|
|
||||||
|
@@unique([organizationId, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model NotificationSubscription {
|
model NotificationSubscription {
|
||||||
@ -163,6 +207,8 @@ model NotificationSubscription {
|
|||||||
keys_p256dh String
|
keys_p256dh String
|
||||||
keys_auth String
|
keys_auth String
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id])
|
||||||
customerId String
|
organizationId String
|
||||||
|
phoneNumber PhoneNumber @relation(fields: [phoneNumberId], references: [id])
|
||||||
|
phoneNumberId String
|
||||||
}
|
}
|
||||||
|
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
version: "3.7"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:13-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- data:/var/lib/postgresql/data
|
||||||
|
env_file: ./.env.local #Here we are using the already existing .env.local file
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
admin:
|
||||||
|
image: adminer
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
2067
package-lock.json
generated
2067
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,11 +42,11 @@
|
|||||||
"@fortawesome/pro-light-svg-icons": "file:./fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz",
|
"@fortawesome/pro-light-svg-icons": "file:./fontawesome/fortawesome-pro-light-svg-icons-5.15.3.tgz",
|
||||||
"@fortawesome/pro-regular-svg-icons": "file:./fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz",
|
"@fortawesome/pro-regular-svg-icons": "file:./fontawesome/fortawesome-pro-regular-svg-icons-5.15.3.tgz",
|
||||||
"@fortawesome/pro-solid-svg-icons": "file:./fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz",
|
"@fortawesome/pro-solid-svg-icons": "file:./fontawesome/fortawesome-pro-solid-svg-icons-5.15.3.tgz",
|
||||||
"@fortawesome/react-fontawesome": "0.1.14",
|
"@fortawesome/react-fontawesome": "0.1.15",
|
||||||
"@headlessui/react": "1.4.0",
|
"@headlessui/react": "1.4.0",
|
||||||
"@heroicons/react": "1.0.3",
|
"@heroicons/react": "1.0.3",
|
||||||
"@hookform/resolvers": "2.6.1",
|
"@hookform/resolvers": "2.6.1",
|
||||||
"@prisma/client": "2.27.0",
|
"@prisma/client": "2.28.0",
|
||||||
"@react-aria/interactions": "3.5.0",
|
"@react-aria/interactions": "3.5.0",
|
||||||
"@tailwindcss/forms": "0.3.3",
|
"@tailwindcss/forms": "0.3.3",
|
||||||
"@tailwindcss/typography": "0.4.1",
|
"@tailwindcss/typography": "0.4.1",
|
||||||
@ -60,7 +60,7 @@
|
|||||||
"pino": "6.13.0",
|
"pino": "6.13.0",
|
||||||
"pino-pretty": "5.1.2",
|
"pino-pretty": "5.1.2",
|
||||||
"postcss": "8.3.6",
|
"postcss": "8.3.6",
|
||||||
"quirrel": "1.6.3",
|
"quirrel": "1.7.0",
|
||||||
"react": "18.0.0-alpha-6f3fcbd6f-20210730",
|
"react": "18.0.0-alpha-6f3fcbd6f-20210730",
|
||||||
"react-dom": "18.0.0-alpha-6f3fcbd6f-20210730",
|
"react-dom": "18.0.0-alpha-6f3fcbd6f-20210730",
|
||||||
"react-hook-form": "7.12.2",
|
"react-hook-form": "7.12.2",
|
||||||
@ -84,7 +84,7 @@
|
|||||||
"lint-staged": "10.5.4",
|
"lint-staged": "10.5.4",
|
||||||
"next-test-api-route-handler": "2.0.2",
|
"next-test-api-route-handler": "2.0.2",
|
||||||
"prettier": "2.3.2",
|
"prettier": "2.3.2",
|
||||||
"prettier-plugin-prisma": "0.14.0",
|
"prettier-plugin-prisma": "2.28.0",
|
||||||
"pretty-quick": "3.1.1",
|
"pretty-quick": "3.1.1",
|
||||||
"preview-email": "3.0.4",
|
"preview-email": "3.0.4",
|
||||||
"prisma": "2.28.0",
|
"prisma": "2.28.0",
|
||||||
|
7
types.ts
7
types.ts
@ -1,6 +1,8 @@
|
|||||||
import { DefaultCtx, SessionContext, SimpleRolesIsAuthorized } from "blitz";
|
import { DefaultCtx, SessionContext, SimpleRolesIsAuthorized } from "blitz";
|
||||||
|
|
||||||
import { User, Role } from "./db";
|
import { Organization, User, GlobalRole, MembershipRole } from "./db";
|
||||||
|
|
||||||
|
type Role = GlobalRole | MembershipRole;
|
||||||
|
|
||||||
declare module "blitz" {
|
declare module "blitz" {
|
||||||
export interface Ctx extends DefaultCtx {
|
export interface Ctx extends DefaultCtx {
|
||||||
@ -11,7 +13,8 @@ declare module "blitz" {
|
|||||||
isAuthorized: SimpleRolesIsAuthorized<Role>;
|
isAuthorized: SimpleRolesIsAuthorized<Role>;
|
||||||
PublicData: {
|
PublicData: {
|
||||||
userId: User["id"];
|
userId: User["id"];
|
||||||
role: Role;
|
roles: Role[];
|
||||||
|
orgId: Organization["id"];
|
||||||
hasCompletedOnboarding?: true;
|
hasCompletedOnboarding?: true;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user