multi tenancy stuff

This commit is contained in:
m5r 2021-08-06 01:07:15 +08:00
parent b54f9ef43c
commit d20eeb0617
51 changed files with 907 additions and 2542 deletions

View File

@ -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;
}); });

View File

@ -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;
}); });

View File

@ -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,
};
}

View 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;
}

View 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,
};
}

View File

@ -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;
}

View File

@ -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() {

View File

@ -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());
} }
} }

View File

@ -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() {

View File

@ -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,22 +20,36 @@ 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),
try { resolver.authorize(),
await db.notificationSubscription.create({ setDefaultOrganizationId,
data: { enforceSuperAdminIfNotCurrentOrganization,
customerId, async ({ organizationId, phoneNumberId, subscription }) => {
endpoint: subscription.endpoint, const phoneNumber = await db.phoneNumber.findFirst({
expirationTime: subscription.expirationTime, where: { id: phoneNumberId, organizationId },
keys_p256dh: subscription.keys.p256dh, include: { organization: true },
keys_auth: subscription.keys.auth,
},
}); });
} catch (error) { if (!phoneNumber) {
if (error.code !== "P2002") { return;
logger.error(error);
// we might want to `throw error`;
} }
}
}); try {
await db.notificationSubscription.create({
data: {
organizationId,
phoneNumberId,
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh,
keys_auth: subscription.keys.auth,
},
});
} catch (error) {
if (error.code !== "P2002") {
logger.error(error);
// we might want to `throw error`;
}
}
},
);

35
app/core/utils.ts Normal file
View 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;
}

View File

@ -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,
},
});
});

View File

@ -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}`,
}, },
); );
}); });

View File

@ -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}` },
);
}, },
); );

View File

@ -5,31 +5,39 @@ 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 }) => {
return; const phoneNumber = await db.phoneNumber.findFirst({
} where: { id: phoneNumberId, organizationId },
include: { organization: true },
});
if (!phoneNumber) {
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,
from: message.from, content: encrypt(message.body, phoneNumber.organization.encryptionKey),
to: message.to, from: message.from,
status: translateStatus(message.status), to: message.to,
direction: translateDirection(message.direction), status: translateStatus(message.status),
twilioSid: message.sid, direction: translateDirection(message.direction),
sentAt: new Date(message.dateCreated), twilioSid: message.sid,
})) sentAt: new Date(message.dateCreated),
.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;

View File

@ -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 = {

View File

@ -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"*/ },
});
} }
}, },
{ {

View File

@ -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", phoneNumberId: "9292",
customerId: "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(),

View File

@ -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>");

View File

@ -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 },
); );

View File

@ -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}`,
}, },
); );
}); });

View File

@ -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),
}; };
}); });
}); });

View File

@ -1,45 +1,56 @@
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(),
throw new NotFoundError(); setDefaultOrganizationId,
} enforceSuperAdminIfNotCurrentOrganization,
async ({ organizationId }) => {
const messages = await db.message.findMany({ const organization = await db.organization.findFirst({
where: { customerId: customer.id }, where: { id: organizationId },
orderBy: { sentAt: Prisma.SortOrder.asc }, include: { phoneNumbers: true },
}); });
if (!organization) {
let conversations: Record<string, Message[]> = {}; throw new NotFoundError();
for (const message of messages) {
let recipient: string;
if (message.direction === Direction.Outbound) {
recipient = message.to;
} else {
recipient = message.from;
} }
if (!conversations[recipient]) { const phoneNumberId = organization.phoneNumbers[0]!.id;
conversations[recipient] = []; const messages = await db.message.findMany({
} where: { organizationId, phoneNumberId },
orderBy: { sentAt: Prisma.SortOrder.asc },
conversations[recipient]!.push({
...message,
content: decrypt(message.content, customer.encryptionKey),
}); });
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime()); let conversations: Record<string, Message[]> = {};
} for (const message of messages) {
conversations = Object.fromEntries( let recipient: string;
Object.entries(conversations).sort( if (message.direction === Direction.Outbound) {
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(), recipient = message.to;
), } else {
); recipient = message.from;
}
return conversations; if (!conversations[recipient]) {
}); conversations[recipient] = [];
}
conversations[recipient]!.push({
...message,
content: decrypt(message.content, organization.encryptionKey),
});
conversations[recipient]!.sort((a, b) => a.sentAt.getTime() - b.sentAt.getTime());
}
conversations = Object.fromEntries(
Object.entries(conversations).sort(
([, a], [, b]) => b[b.length - 1]!.sentAt.getTime() - a[a.length - 1]!.sentAt.getTime(),
),
);
return conversations;
},
);

View File

@ -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,14 +39,16 @@ 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)
smsApplicationSid: twimlAppSid, .incomingPhoneNumbers.get(phoneNumber.id)
voiceApplicationSid: twimlAppSid, .update({
}), smsApplicationSid: twimlAppSid,
voiceApplicationSid: twimlAppSid,
}),
]); ]);
}); });

View File

@ -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}` },
),
]); ]);
}); });

View File

@ -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,
}, },
}); });
}, },

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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,

View File

@ -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>
</> </>
); );

View File

@ -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}`,
}, },
); );
}); });

View File

@ -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),

View File

@ -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>

View File

@ -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;
} }

View File

@ -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
const {
items: phoneCalls,
hasMore,
nextPage,
count,
} = await paginate({
skip,
take,
count: () => db.phoneCall.count({ where }),
query: (paginateArgs) => db.phoneCall.findMany({ ...paginateArgs, where, orderBy }),
});
return { return db.phoneCall.findMany({
phoneCalls, where: { organizationId, phoneNumberId },
nextPage, orderBy: { createdAt: Prisma.SortOrder.asc },
hasMore, });
count, });
};
},
);

View File

@ -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,
},
});
});

View 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,
},
});
},
);

View File

@ -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,
},
}),
);

View File

@ -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);

View File

@ -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");

View File

@ -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,

View 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,
},
},
},
},
},
});
}

View 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;

View File

@ -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";

View 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;

View File

@ -12,18 +12,68 @@ generator client {
// -------------------------------------- // --------------------------------------
model User { model Organization {
id String @id @default(uuid()) id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz
name String? encryptionKey String
email String @unique paddleCustomerId String?
hashedPassword String? paddleSubscriptionId String?
role Role @default(USER)
tokens Token[] twilioAccountSid String?
sessions Session[] twilioAuthToken String? // TODO: encrypt it with encryptionKey
customer Customer[] 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 {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
name String?
email String @unique
hashedPassword String?
role GlobalRole @default(CUSTOMER)
memberships Membership[]
tokens Token[]
sessions Session[]
} }
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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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;
}; };
} }