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 crypto from "crypto";
import CookieStore from "../lib/cookie-store";
import Session from "../lib/session";
type Authentication =
| "none"
| "auth0"
@ -93,15 +90,13 @@ function writeSessionToCookie(
res: ServerResponse,
authentication: Authentication,
) {
const cookieStore = new CookieStore();
const session: Session = new Session({
const session = {
id: `${authentication}|userId`,
email: "test@fss.test",
name: "Groot",
teamId: "teamId",
role: "owner",
});
cookieStore.save(req, res, session);
};
const setCookieHeader = res.getHeader("Set-Cookie") as string[];
// 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", () => ({
sendEmail: jest.fn(),
}));
jest.mock("../../../../database/users", () => ({ createUser: jest.fn() }));
jest.mock("../../../../database/teams", () => ({ createTeam: jest.fn() }));
jest.mock("../../../../database/customer", () => ({ createCustomer: jest.fn() }));
import { parse } from "set-cookie-parser";
import { callApiHandler } from "../../../../../jest/helpers";
import signUpHandler from "../../../../pages/api/auth/sign-up";
import { sessionName } from "../../../../../lib/cookie-store";
import { sendEmail } from "../../../../pages/api/_send-email";
import { createUser } from "../../../../database/users";
import { createTeam } from "../../../../database/teams";
import { createCustomer } from "../../../../database/customer";
const sessionName = "";
describe("/api/auth/sign-up", () => {
const mockedSendEmail = sendEmail as jest.Mock<
ReturnType<typeof sendEmail>
>;
const mockedCreateUser = createUser as jest.Mock<
ReturnType<typeof createUser>
>;
const mockedCreateTeam = createTeam as jest.Mock<
ReturnType<typeof createTeam>
const mockedCreateCustomer = createCustomer as jest.Mock<
ReturnType<typeof createCustomer>
>;
beforeEach(() => {
mockedSendEmail.mockClear();
mockedCreateUser.mockClear();
mockedCreateTeam.mockClear();
mockedCreateCustomer.mockClear();
});
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 () => {
mockedCreateUser.mockResolvedValue({
/*mockedCreateCustomer.mockResolvedValue({
id: "auth0|1234567",
teamId: "98765",
role: "owner",
email: "test@fss.dev",
name: "Groot",
createdAt: new Date(),
updatedAt: new Date(),
});
mockedCreateTeam.mockResolvedValue({
id: "98765",
subscriptionId: null,
teamMembersLimit: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
});*/
const body = {
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("");
useEffect(() => {
setValue("name", user.userProfile?.name ?? "");
setValue("name", user.userProfile?.user_metadata.name ?? "");
setValue("email", user.userProfile?.email ?? "");
}, [setValue, user.userProfile]);
@ -40,7 +40,7 @@ const ProfileInformations: FunctionComponent = () => {
}
try {
await user.updateUser({ name, email });
await user.updateUser({ email, data: { name } });
} catch (error) {
logger.error(error.response, "error updating user infos");

View File

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

View File

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

View File

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

View File

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

View File

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