build pass

This commit is contained in:
m5r 2021-07-19 00:15:06 +08:00
parent 9f5e646338
commit 3762305c4f
12 changed files with 72 additions and 377 deletions

View File

@ -7,9 +7,6 @@ import listen from "test-listen";
import fetch from "isomorphic-fetch"; import fetch from "isomorphic-fetch";
import crypto from "crypto"; import crypto from "crypto";
import CookieStore from "../lib/cookie-store";
import Session from "../lib/session";
type Authentication = type Authentication =
| "none" | "none"
| "auth0" | "auth0"
@ -93,15 +90,13 @@ function writeSessionToCookie(
res: ServerResponse, res: ServerResponse,
authentication: Authentication, authentication: Authentication,
) { ) {
const cookieStore = new CookieStore(); const session = {
const session: Session = new Session({
id: `${authentication}|userId`, id: `${authentication}|userId`,
email: "test@fss.test", email: "test@fss.test",
name: "Groot", name: "Groot",
teamId: "teamId", teamId: "teamId",
role: "owner", role: "owner",
}); };
cookieStore.save(req, res, session);
const setCookieHeader = res.getHeader("Set-Cookie") as string[]; const setCookieHeader = res.getHeader("Set-Cookie") as string[];
// write it to request headers to immediately have access to the user's session // write it to request headers to immediately have access to the user's session

View File

@ -1,36 +1,28 @@
jest.mock("../../../../pages/api/user/_auth0", () => ({
setAppMetadata: jest.fn(),
}));
jest.mock("../../../../pages/api/_send-email", () => ({ jest.mock("../../../../pages/api/_send-email", () => ({
sendEmail: jest.fn(), sendEmail: jest.fn(),
})); }));
jest.mock("../../../../database/users", () => ({ createUser: jest.fn() })); jest.mock("../../../../database/customer", () => ({ createCustomer: jest.fn() }));
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
import { parse } from "set-cookie-parser"; import { parse } from "set-cookie-parser";
import { callApiHandler } from "../../../../../jest/helpers"; import { callApiHandler } from "../../../../../jest/helpers";
import signUpHandler from "../../../../pages/api/auth/sign-up"; import signUpHandler from "../../../../pages/api/auth/sign-up";
import { sessionName } from "../../../../../lib/cookie-store";
import { sendEmail } from "../../../../pages/api/_send-email"; import { sendEmail } from "../../../../pages/api/_send-email";
import { createUser } from "../../../../database/users"; import { createCustomer } from "../../../../database/customer";
import { createTeam } from "../../../../database/teams";
const sessionName = "";
describe("/api/auth/sign-up", () => { describe("/api/auth/sign-up", () => {
const mockedSendEmail = sendEmail as jest.Mock< const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail> ReturnType<typeof sendEmail>
>; >;
const mockedCreateUser = createUser as jest.Mock< const mockedCreateCustomer = createCustomer as jest.Mock<
ReturnType<typeof createUser> ReturnType<typeof createCustomer>
>;
const mockedCreateTeam = createTeam as jest.Mock<
ReturnType<typeof createTeam>
>; >;
beforeEach(() => { beforeEach(() => {
mockedSendEmail.mockClear(); mockedSendEmail.mockClear();
mockedCreateUser.mockClear(); mockedCreateCustomer.mockClear();
mockedCreateTeam.mockClear();
}); });
test("responds 405 to GET", async () => { test("responds 405 to GET", async () => {
@ -46,22 +38,11 @@ describe("/api/auth/sign-up", () => {
}); });
test("responds 200 to POST with body from email login", async () => { test("responds 200 to POST with body from email login", async () => {
mockedCreateUser.mockResolvedValue({ /*mockedCreateCustomer.mockResolvedValue({
id: "auth0|1234567", id: "auth0|1234567",
teamId: "98765",
role: "owner",
email: "test@fss.dev", email: "test@fss.dev",
name: "Groot", name: "Groot",
createdAt: new Date(), });*/
updatedAt: new Date(),
});
mockedCreateTeam.mockResolvedValue({
id: "98765",
subscriptionId: null,
teamMembersLimit: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
const body = { const body = {
accessToken: accessToken:

View File

@ -1,15 +0,0 @@
import { FunctionComponent } from "react";
import usePress from "react-gui/use-press";
const LongPressHandler: FunctionComponent = ({ children }) => {
const onLongPress = (event: any) => console.log("event", event);
const ref = usePress({ onLongPress });
return (
<div ref={ref} onContextMenu={e => e.preventDefault()}>
{children}
</div>
);
};
export default LongPressHandler;

View File

@ -1,215 +0,0 @@
import type { FunctionComponent } from "react";
import { useState } from "react";
import clsx from "clsx";
import { CheckIcon } from "@heroicons/react/outline";
import useUser from "../../hooks/use-user";
import useSubscription from "../../hooks/use-subscription";
import type { Plan, PlanId } from "../../subscription/plans";
import {
FREE,
MONTHLY,
ANNUALLY,
TEAM_MONTHLY,
TEAM_ANNUALLY,
} from "../../subscription/plans";
type Props = {
activePlanId?: PlanId;
};
const PLANS: Record<BillingSchedule, Plan[]> = {
monthly: [FREE, MONTHLY, TEAM_MONTHLY],
yearly: [FREE, ANNUALLY, TEAM_ANNUALLY],
};
const PricingPlans: FunctionComponent<Props> = ({ activePlanId }) => {
const [billingSchedule, setBillingSchedule] = useState<BillingSchedule>(
"monthly",
);
return (
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="sm:flex sm:flex-col sm:align-center">
<div className="relative self-center mt-6 bg-gray-100 rounded-lg p-0.5 flex sm:mt-8">
<button
onClick={() => setBillingSchedule("monthly")}
type="button"
className={clsx(
"relative w-1/2 border-gray-200 rounded-md shadow-sm py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
{
"bg-white": billingSchedule === "monthly",
},
)}
>
Monthly billing
</button>
<button
onClick={() => setBillingSchedule("yearly")}
type="button"
className={clsx(
"ml-0.5 relative w-1/2 border border-transparent rounded-md py-2 text-sm font-medium text-gray-700 whitespace-nowrap focus:outline-none focus:ring-2 focus:ring-primary-500 focus:z-10 sm:w-auto sm:px-8",
{
"bg-white": billingSchedule === "yearly",
},
)}
>
Yearly billing
</button>
</div>
</div>
<div className="mt-6 space-y-4 flex flex-row flex-wrap sm:mt-10 sm:space-y-0 sm:gap-6 lg:max-w-4xl lg:mx-auto">
{PLANS[billingSchedule].map((plan) => (
<PricingPlan
key={`pricing-plan-${plan.name}`}
plan={plan}
billingSchedule={billingSchedule}
activePlanId={activePlanId}
/>
))}
</div>
</div>
);
};
export default PricingPlans;
type BillingSchedule = "yearly" | "monthly";
type PricingPlanProps = {
plan: Plan;
billingSchedule: BillingSchedule;
activePlanId?: PlanId;
};
const PricingPlan: FunctionComponent<PricingPlanProps> = ({
plan,
billingSchedule,
activePlanId,
}) => {
const { subscribe, changePlan } = useSubscription();
const { userProfile } = useUser();
const { name, description, features, price, id } = plan;
const isActivePlan =
(typeof activePlanId !== "undefined" ? activePlanId : "free") === id;
function movePlan() {
const teamId = userProfile!.teamId;
const email = userProfile!.email;
const planId = plan.id;
if (typeof activePlanId === "undefined" && typeof planId === "number") {
return subscribe({ email, teamId, planId });
}
changePlan({ planId });
}
return (
<div
className={clsx(
"bg-white w-full flex-grow border rounded-lg shadow-sm divide-y divide-gray-200 sm:w-auto",
{
"border-gray-200": !isActivePlan,
"border-primary-600": isActivePlan,
},
)}
>
<div className="p-6">
<h2 className="text-lg leading-6 font-medium text-gray-900">
{name}
</h2>
<p className="mt-4 text-sm text-gray-500">{description}</p>
<p className="mt-8">
<PlanPrice
price={price}
billingSchedule={billingSchedule}
/>
</p>
<div className="mt-8">
<PlanButton
name={name}
isActivePlan={isActivePlan}
changePlan={movePlan}
/>
</div>
</div>
<div className="pt-6 pb-8 px-6">
<h3 className="text-xs font-medium text-gray-900 tracking-wide uppercase">
What&apos;s included
</h3>
<ul className="mt-6 space-y-4">
{features.map((feature) => (
<li
key={`pricing-plan-${name}-feature-${feature}`}
className="flex space-x-3"
>
<CheckIcon className="flex-shrink-0 h-5 w-5 text-green-500" />
<span className="text-sm text-gray-500">
{feature}
</span>
</li>
))}
</ul>
</div>
</div>
);
};
type PlanButtonProps = {
name: Plan["name"];
isActivePlan: boolean;
changePlan: () => void;
};
const PlanButton: FunctionComponent<PlanButtonProps> = ({
name,
isActivePlan,
changePlan,
}) => {
return isActivePlan ? (
<div className="block w-full py-2 text-sm font-semibold text-gray-500 text-center">
You&apos;re currently on this plan
</div>
) : (
<button
type="button"
onClick={changePlan}
className="block w-full cursor-pointer bg-primary-600 border border-primary-600 rounded-md py-2 text-sm font-semibold text-white text-center hover:bg-primary-700"
>
Move to <span className="font-bold">{name}</span> plan
</button>
);
};
type PlanPriceProps = {
price: Plan["price"];
billingSchedule: BillingSchedule;
};
const PlanPrice: FunctionComponent<PlanPriceProps> = ({
price,
billingSchedule,
}) => {
if (price === "free") {
return (
<span className="text-4xl font-extrabold text-gray-900">Free</span>
);
}
return (
<>
<span className="text-4xl font-extrabold text-gray-900">
${price}
</span>
<span className="text-base font-medium text-gray-500">/mo</span>
{billingSchedule === "yearly" ? (
<span className="ml-1 text-sm text-gray-500">
billed yearly
</span>
) : null}
</>
);
};

View File

@ -30,7 +30,7 @@ const ProfileInformations: FunctionComponent = () => {
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
useEffect(() => { useEffect(() => {
setValue("name", user.userProfile?.name ?? ""); setValue("name", user.userProfile?.user_metadata.name ?? "");
setValue("email", user.userProfile?.email ?? ""); setValue("email", user.userProfile?.email ?? "");
}, [setValue, user.userProfile]); }, [setValue, user.userProfile]);
@ -40,7 +40,7 @@ const ProfileInformations: FunctionComponent = () => {
} }
try { try {
await user.updateUser({ name, email }); await user.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

@ -1,5 +1,6 @@
import type { PlanId } from "../subscription/plans"; import type { PlanId } from "../subscription/plans";
import appLogger from "../../lib/logger"; import appLogger from "../../lib/logger";
import supabase from "../supabase/server";
const logger = appLogger.child({ module: "subscriptions" }); const logger = appLogger.child({ module: "subscriptions" });
@ -32,12 +33,6 @@ export type Subscription = {
updatedAt: Date; updatedAt: Date;
}; };
type FirestoreSubscription = FirestoreEntry<Subscription>;
const subscriptions = firestoreCollection<FirestoreSubscription>(
"subscriptions",
);
type CreateSubscriptionParams = Pick< type CreateSubscriptionParams = Pick<
Subscription, Subscription,
| "userId" | "userId"
@ -62,8 +57,10 @@ export async function createSubscription({
cancelUrl, cancelUrl,
lastEventTime, lastEventTime,
}: CreateSubscriptionParams): Promise<Subscription> { }: CreateSubscriptionParams): Promise<Subscription> {
const createdAt = FieldValue.serverTimestamp() as Timestamp; const createdAt = new Date();
await subscriptions.doc(paddleSubscriptionId).set({ const { data } = await supabase
.from<Subscription>("subscription")
.insert({
userId, userId,
planId, planId,
paddleCheckoutId, paddleCheckoutId,
@ -75,11 +72,10 @@ export async function createSubscription({
lastEventTime, lastEventTime,
createdAt, createdAt,
updatedAt: createdAt, updatedAt: createdAt,
}); })
.throwOnError();
const subscription = await findSubscription({ paddleSubscriptionId }); return data![0];
return subscription!;
} }
type GetSubscriptionParams = Pick<Subscription, "paddleSubscriptionId">; type GetSubscriptionParams = Pick<Subscription, "paddleSubscriptionId">;
@ -87,14 +83,15 @@ type GetSubscriptionParams = Pick<Subscription, "paddleSubscriptionId">;
export async function findSubscription({ export async function findSubscription({
paddleSubscriptionId, paddleSubscriptionId,
}: GetSubscriptionParams): Promise<Subscription | undefined> { }: GetSubscriptionParams): Promise<Subscription | undefined> {
const subscriptionDocument = await subscriptions const { error, data } = await supabase
.doc(paddleSubscriptionId) .from<Subscription>("subscription")
.get(); .select("*")
if (!subscriptionDocument.exists) { .eq("paddleSubscriptionId", paddleSubscriptionId)
return; .single();
}
return convertFromFirestore(subscriptionDocument.data()!); if (error) throw error;
return data!;
} }
type FindUserSubscriptionParams = Pick<Subscription, "userId">; type FindUserSubscriptionParams = Pick<Subscription, "userId">;
@ -102,18 +99,16 @@ type FindUserSubscriptionParams = Pick<Subscription, "userId">;
export async function findUserSubscription({ export async function findUserSubscription({
userId, userId,
}: FindUserSubscriptionParams): Promise<Subscription | null> { }: FindUserSubscriptionParams): Promise<Subscription | null> {
const subscriptionDocumentsSnapshot = await subscriptions const { error, data } = await supabase
.where("userId", "==", userId) .from<Subscription>("subscription")
.where("status", "!=", "deleted") .select("*")
.get(); .eq("userId", userId)
if (subscriptionDocumentsSnapshot.docs.length === 0) { .neq("status", "deleted")
logger.warn(`No subscription found for user ${userId}`); .single();
return null;
}
const subscriptionDocument = subscriptionDocumentsSnapshot.docs[0].data(); if (error) throw error;
return convertFromFirestore(subscriptionDocument); return data!;
} }
type UpdateSubscriptionParams = Pick<Subscription, "paddleSubscriptionId"> & type UpdateSubscriptionParams = Pick<Subscription, "paddleSubscriptionId"> &
@ -135,17 +130,22 @@ export async function updateSubscription(
update: UpdateSubscriptionParams, update: UpdateSubscriptionParams,
): Promise<void> { ): Promise<void> {
const paddleSubscriptionId = update.paddleSubscriptionId; const paddleSubscriptionId = update.paddleSubscriptionId;
await subscriptions.doc(paddleSubscriptionId).set( await supabase
{ .from<Subscription>("subscription")
.update({
...update, ...update,
updatedAt: FieldValue.serverTimestamp() as Timestamp, updatedAt: new Date(),
}, })
{ merge: true }, .eq("paddleSubscriptionId", paddleSubscriptionId)
); .throwOnError();
} }
export async function deleteSubscription({ export async function deleteSubscription({
paddleSubscriptionId, paddleSubscriptionId,
}: Pick<Subscription, "paddleSubscriptionId">): Promise<void> { }: Pick<Subscription, "paddleSubscriptionId">): Promise<void> {
await subscriptions.doc(paddleSubscriptionId).delete(); await supabase
.from<Subscription>("subscription")
.delete()
.eq("paddleSubscriptionId", paddleSubscriptionId)
.throwOnError();
} }

View File

@ -8,7 +8,6 @@ import {
SUBSCRIPTION_STATUSES, SUBSCRIPTION_STATUSES,
updateSubscription, updateSubscription,
} from "../../../database/subscriptions"; } from "../../../database/subscriptions";
import { FREE } from "../../../subscription/plans";
import appLogger from "../../../../lib/logger"; import appLogger from "../../../../lib/logger";
const logger = appLogger.child({ module: "subscription-cancelled" }); const logger = appLogger.child({ module: "subscription-cancelled" });

View File

@ -11,7 +11,7 @@ import {
import { sendEmail } from "../_send-email"; import { sendEmail } from "../_send-email";
import type { ApiError } from "../_types"; import type { ApiError } from "../_types";
import appLogger from "../../../../lib/logger"; import appLogger from "../../../../lib/logger";
import { PAID_PLANS } from "../../../subscription/plans"; import { PLANS } from "../../../subscription/plans";
const logger = appLogger.child({ module: "subscription-created" }); const logger = appLogger.child({ module: "subscription-created" });
@ -94,7 +94,7 @@ export async function subscriptionCreatedHandler(
cancelUrl, cancelUrl,
}); });
const nextPlan = PAID_PLANS[planId]; const nextPlan = PLANS[planId];
sendEmail({ sendEmail({
subject: "Thanks for your purchase", subject: "Thanks for your purchase",
body: `Welcome to ${nextPlan.name} plan`, body: `Welcome to ${nextPlan.name} plan`,

View File

@ -8,9 +8,10 @@ import {
SUBSCRIPTION_STATUSES, SUBSCRIPTION_STATUSES,
updateSubscription, updateSubscription,
} from "../../../database/subscriptions"; } from "../../../database/subscriptions";
import { PAID_PLANS } from "../../../subscription/plans"; import { PLANS } from "../../../subscription/plans";
import appLogger from "../../../../lib/logger"; import appLogger from "../../../../lib/logger";
import { sendEmail } from "../_send-email"; import { sendEmail } from "../_send-email";
import { findCustomer } from "../../../database/customer";
const logger = appLogger.child({ module: "subscription-updated" }); const logger = appLogger.child({ module: "subscription-updated" });
@ -69,7 +70,7 @@ export async function subscriptionUpdated(
const updateUrl = body.update_url; const updateUrl = body.update_url;
const cancelUrl = body.cancel_url; const cancelUrl = body.cancel_url;
const planId = body.subscription_plan_id; const planId = body.subscription_plan_id;
const nextPlan = PAID_PLANS[planId]; const nextPlan = PLANS[planId];
await updateSubscription({ await updateSubscription({
paddleSubscriptionId, paddleSubscriptionId,
planId, planId,
@ -79,7 +80,7 @@ export async function subscriptionUpdated(
cancelUrl, cancelUrl,
}); });
const user = await findUser({ id: subscription.userId }); const user = await findCustomer(subscription.userId);
sendEmail({ sendEmail({
subject: "Thanks for your purchase", subject: "Thanks for your purchase",

View File

@ -24,7 +24,7 @@ const bodySchema = Joi.object<Body>({
export default withApiAuthRequired<Response>(async function updateSubscription( export default withApiAuthRequired<Response>(async function updateSubscription(
req, req,
res, res,
session, user,
) { ) {
if (req.method !== "POST") { if (req.method !== "POST") {
const statusCode = 405; const statusCode = 405;
@ -57,7 +57,7 @@ export default withApiAuthRequired<Response>(async function updateSubscription(
const { planId }: Body = validationResult.value; const { planId }: Body = validationResult.value;
const subscription = await findUserSubscription({ const subscription = await findUserSubscription({
teamId: session.user.teamId, userId: user.id,
}); });
if (!subscription) { if (!subscription) {
const statusCode = 500; const statusCode = 500;

View File

@ -4,6 +4,7 @@ import { deleteSubscription } from "../../../database/subscriptions";
import { cancelPaddleSubscription } from "../../../subscription/_paddle-api"; import { cancelPaddleSubscription } from "../../../subscription/_paddle-api";
import appLogger from "../../../../lib/logger"; import appLogger from "../../../../lib/logger";
import supabase from "../../../supabase/server";
type Response = void | ApiError; type Response = void | ApiError;
@ -12,7 +13,7 @@ const logger = appLogger.child({ route: "/api/user/delete-user" });
export default withApiAuthRequired<Response>(async function deleteUserHandler( export default withApiAuthRequired<Response>(async function deleteUserHandler(
req, req,
res, res,
session, user,
) { ) {
if (req.method !== "POST") { if (req.method !== "POST") {
const statusCode = 405; const statusCode = 405;
@ -27,34 +28,12 @@ export default withApiAuthRequired<Response>(async function deleteUserHandler(
return; return;
} }
const { id: userId, role, teamId } = session.user;
const team = await findTeam({ id: teamId });
const subscriptionId = team!.subscriptionId;
try { try {
let actions: Promise<any>[] = [ let actions: Promise<any>[] = [
deleteAuth0User({ id: userId }), supabase.auth.api.deleteUser(user.id, ""),
deleteUser({ id: userId, teamId }),
]; ];
if (role === "owner") { // TODO: delete user phone number/messages
const teamMembers = await findUsersByTeam({ teamId });
teamMembers.forEach((member) =>
actions.push(deleteUser({ id: member.id, teamId })),
);
actions.push(deleteTeam({ id: teamId }));
if (subscriptionId) {
actions.push(
cancelPaddleSubscription({ subscriptionId }),
deleteSubscription({
paddleSubscriptionId: subscriptionId,
}),
);
}
}
await Promise.all(actions); await Promise.all(actions);
res.status(200).end(); res.status(200).end();

View File

@ -1,30 +0,0 @@
import type { ApiError } from "../_types";
import type Session from "../../../../lib/session";
import {
sessionCache,
withApiAuthRequired,
} from "../../../../lib/session-helpers";
import appLogger from "../../../../lib/logger";
type Response = Session | ApiError;
const logger = appLogger.child({ route: "/api/user/session" });
export default withApiAuthRequired<Response>(async function session(req, res) {
if (req.method !== "GET") {
const statusCode = 405;
const apiError: ApiError = {
statusCode,
errorMessage: `Method ${req.method} Not Allowed`,
};
logger.error(apiError);
res.setHeader("Allow", ["GET"]);
res.status(statusCode).send(apiError);
return;
}
const session = sessionCache.get(req, res)!;
res.status(200).send(session);
});