Merge pull request #2 from MarconLP/feature/posthog-event-tracking

Feature/posthog event tracking
This commit is contained in:
Marcus Hof 2023-04-23 19:53:26 +02:00 committed by GitHub
commit 8ae1fce9d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 559 additions and 148 deletions

54
package-lock.json generated
View file

@ -34,8 +34,9 @@
"micro": "^10.0.1",
"micro-cors": "^0.1.1",
"next": "^13.3.0",
"next-auth": "^4.21.0",
"next-auth": "^4.22.1",
"posthog-js": "^1.53.4",
"posthog-node": "^3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-media-recorder": "^1.6.6",
@ -5349,9 +5350,9 @@
}
},
"node_modules/next-auth": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.0.tgz",
"integrity": "sha512-08+kjnDoE7aQ52O996x6cwA3ffc2CbHIkrCgLYhbE+aDIJBKI0oA9UbIEIe19/+ODYJgpAHHOtJx4izmsgaVag==",
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.1.tgz",
"integrity": "sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
@ -5858,6 +5859,26 @@
"rrweb-snapshot": "^1.1.14"
}
},
"node_modules/posthog-node": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-3.1.0.tgz",
"integrity": "sha512-hXhkDWigzNYgkfLpd3HbfCD0h1/zP19pPXEba2Daf6xCerrFxc7ixMDtXwCQMXOmJNvrcoVMvrjULpfKHN11vA==",
"dependencies": {
"axios": "^0.27.0"
},
"engines": {
"node": ">=15.0.0"
}
},
"node_modules/posthog-node/node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/preact": {
"version": "10.13.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz",
@ -11126,9 +11147,9 @@
}
},
"next-auth": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.0.tgz",
"integrity": "sha512-08+kjnDoE7aQ52O996x6cwA3ffc2CbHIkrCgLYhbE+aDIJBKI0oA9UbIEIe19/+ODYJgpAHHOtJx4izmsgaVag==",
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.1.tgz",
"integrity": "sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==",
"requires": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
@ -11451,6 +11472,25 @@
"rrweb-snapshot": "^1.1.14"
}
},
"posthog-node": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-3.1.0.tgz",
"integrity": "sha512-hXhkDWigzNYgkfLpd3HbfCD0h1/zP19pPXEba2Daf6xCerrFxc7ixMDtXwCQMXOmJNvrcoVMvrjULpfKHN11vA==",
"requires": {
"axios": "^0.27.0"
},
"dependencies": {
"axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
}
}
},
"preact": {
"version": "10.13.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz",

View file

@ -42,8 +42,9 @@
"micro": "^10.0.1",
"micro-cors": "^0.1.1",
"next": "^13.3.0",
"next-auth": "^4.21.0",
"next-auth": "^4.22.1",
"posthog-js": "^1.53.4",
"posthog-node": "^3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-media-recorder": "^1.6.6",

View file

@ -5,22 +5,36 @@ import { useAtom } from "jotai";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import paywallAtom from "~/atoms/paywallAtom";
import { useSession } from "next-auth/react";
import { usePostHog } from "posthog-js/react";
export default function NewVideoMenu() {
const [, setRecordOpen] = useAtom(recordVideoModalOpen);
const [, setUploadOpen] = useAtom(uploadVideoModalOpen);
const [, setPaywallOpen] = useAtom(paywallAtom);
const { data: session } = useSession();
const posthog = usePostHog();
const openRecordModal = () => {
setRecordOpen(true);
posthog?.capture("open record video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
};
const openUploadModal = () => {
if (session?.user.stripeSubscriptionStatus === "active") {
setUploadOpen(true);
posthog?.capture("open upload video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
} else {
setPaywallOpen(true);
posthog?.capture("hit video upload paywall", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
}
};

View file

@ -7,6 +7,7 @@ import { api } from "~/utils/api";
import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/20/solid";
import Tooltip from "~/components/Tooltip";
import { usePostHog } from "posthog-js/react";
export default function Paywall() {
const { mutateAsync: createCheckoutSession } =
@ -14,9 +15,12 @@ export default function Paywall() {
const router = useRouter();
const [open, setOpen] = useAtom(paywallAtom);
const [billedAnnually, setBilledAnnually] = useState<boolean>(false);
const posthog = usePostHog();
function closeModal() {
setOpen(false);
posthog?.capture("close paywall");
}
const handleCheckout = async () => {
@ -26,6 +30,12 @@ export default function Paywall() {
}
};
const toggleBillingCycle = () => {
setBilledAnnually(!billedAnnually);
posthog?.capture("change billing cycle");
};
return (
<Transition appear show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
@ -85,7 +95,7 @@ export default function Paywall() {
<span className=" svelte-10wstod">Monthly</span>
<label className="group relative flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-600">
<input
onChange={() => setBilledAnnually(!billedAnnually)}
onChange={toggleBillingCycle}
checked={billedAnnually}
type="checkbox"
className="peer absolute left-1/2 hidden h-full w-full -translate-x-1/2 rounded-md"

View file

@ -3,12 +3,26 @@ import { Fragment } from "react";
import { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/router";
import { api } from "~/utils/api";
import { usePostHog } from "posthog-js/react";
export default function ProfileMenu() {
const { mutateAsync: createBillingPortalSession } =
api.stripe.createBillingPortalSession.useMutation();
const { push } = useRouter();
const { data: session } = useSession();
const posthog = usePostHog();
const openBillingSettings = () => {
void createBillingPortalSession().then(({ billingPortalUrl }) => {
if (billingPortalUrl) {
void push(billingPortalUrl);
}
});
posthog?.capture("billing settings opened", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
};
return (
<Menu as="div" className="relative inline-block text-left">
@ -34,15 +48,7 @@ export default function ProfileMenu() {
<Menu.Item>
{({ active }) => (
<div
onClick={() => {
void createBillingPortalSession().then(
({ billingPortalUrl }) => {
if (billingPortalUrl) {
void push(billingPortalUrl);
}
}
);
}}
onClick={openBillingSettings}
className={`mx-2 flex h-8 w-40 cursor-pointer flex-row content-center rounded-md p-2 ${
active ? "bg-gray-100" : ""
}`}

View file

@ -15,6 +15,7 @@ import { TRPCClientError } from "@trpc/client";
import { useAtom } from "jotai/index";
import paywallAtom from "~/atoms/paywallAtom";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import { usePostHog } from "posthog-js/react";
interface Props {
closeModal: () => void;
@ -45,6 +46,7 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
const [duration, setDuration] = useState<number>(0);
const [, setPaywallOpen] = useAtom(paywallAtom);
const videoRef = useRef<null | HTMLVideoElement>(null);
const posthog = usePostHog();
const handleRecording = async () => {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
@ -80,6 +82,8 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
recorderRef.current.startRecording();
setStep("in");
posthog?.capture("recorder: start video recording");
};
const handleStop = () => {
@ -98,6 +102,8 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
});
setStep("post");
posthog?.capture("recorder: video recording finished");
};
const handleDelete = () => {
@ -109,6 +115,8 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
closeModal();
setStep("pre");
posthog?.capture("recorder: video deleted");
};
const handlePause = () => {
@ -119,6 +127,7 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
} else {
recorderRef.current.pauseRecording();
}
posthog?.capture("recorder: recording paused/resumed", { pause });
setPause(!pause);
}
};
@ -146,6 +155,8 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
"Recording - " + dayjs().format("D MMM YYYY") + ".webm";
invokeSaveAsDialog(blob, dateString);
}
posthog?.capture("recorder: video downloaded");
};
const generateThumbnail = async (video: HTMLVideoElement) => {
@ -187,6 +198,7 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
.then(() => {
void router.push("share/" + id);
setRecordOpen(false);
posthog?.capture("recorder: video uploaded");
})
.catch((err) => {
console.error(err);
@ -197,6 +209,7 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
err.message ===
"Sorry, you have reached the maximum video upload limit on our free tier. Please upgrade to upload more."
) {
posthog?.capture("recorder: video upload paywall hit");
setPaywallOpen(true);
}
} else {
@ -325,6 +338,8 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
<video
src={URL.createObjectURL(blob)}
controls
onPlay={() => posthog?.capture("recorder: played preview video")}
onPause={() => posthog?.capture("recorder: paused preview video")}
ref={videoRef}
className="mb-4 w-[75vw]"
/>
@ -374,7 +389,10 @@ export default function Recorder({ closeModal, step, setStep }: Props) {
<button
type="button"
className="ml-auto inline-flex items-center rounded-md bg-indigo-500 px-4 py-2 text-sm font-semibold leading-6 text-white shadow transition duration-150 ease-in-out hover:bg-indigo-400 disabled:cursor-not-allowed"
onClick={() => void closeModal()}
onClick={() => {
posthog?.capture("recorder: closed post-modal");
void closeModal();
}}
>
Close
</button>

View file

@ -3,6 +3,7 @@ import { Fragment, useState } from "react";
import { ModernSwitch } from "~/components/ModernSwitch";
import { api, type RouterOutputs } from "~/utils/api";
import ExpireDateSelectMenu from "~/components/ExpireDateSelectMenu";
import { usePostHog } from "posthog-js/react";
interface Props {
video: RouterOutputs["video"]["get"];
@ -11,6 +12,23 @@ interface Props {
export function ShareModal({ video }: Props) {
const utils = api.useContext();
const [open, setOpen] = useState<boolean>(false);
const posthog = usePostHog();
const openModal = () => {
setOpen(true);
posthog?.capture("open video share modal", {
videoSharing: video.sharing,
videoId: video.id,
});
};
const closeModal = () => {
setOpen(false);
posthog?.capture("close video share modal", {
videoSharing: video.sharing,
videoId: video.id,
});
};
const setSharingMutation = api.video.setSharing.useMutation({
onMutate: async ({ videoId, sharing }) => {
@ -58,23 +76,24 @@ export function ShareModal({ video }: Props) {
setTimeout(() => {
setLinkCopied(false);
}, 5000);
posthog?.capture("public video link copied", {
videoSharing: video.sharing,
videoId: video.id,
});
};
return (
<>
<span
onClick={() => setOpen(true)}
onClick={openModal}
className="ml-4 cursor-pointer rounded border border-[#0000001a] px-2 py-2 text-sm text-[#292d34] hover:bg-[#fafbfc]"
>
Share
</span>
<Transition appear show={open} as={Fragment}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setOpen(false)}
>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -146,19 +165,6 @@ export function ShareModal({ video }: Props) {
}
/>
</div>
{/*<div className="mt-3 flex h-6 items-center justify-between">*/}
{/* <span>Share link with search engines</span>*/}
{/* <ModernSwitch*/}
{/* enabled={video.linkShareSeo}*/}
{/* toggle={() => console.log("test")}*/}
{/* />*/}
{/*</div>*/}
{/*<div className="mt-3 flex h-6 items-center justify-between">*/}
{/* <span>Embed code</span>*/}
{/* <button className="h-6 rounded border border-solid border-[#d5d9df] bg-white px-[7px] font-medium">*/}
{/* Copy code*/}
{/* </button>*/}
{/*</div>*/}
</div>
</>
) : null}

View file

@ -8,6 +8,7 @@ import {
TrashIcon,
} from "@radix-ui/react-icons";
import { useRouter } from "next/router";
import { usePostHog } from "posthog-js/react";
interface Props {
video: RouterOutputs["video"]["get"];
@ -18,6 +19,7 @@ export default function VideoMoreMenu({ video }: Props) {
const [renameMenuOpen, setRenameMenuOpen] = useState<boolean>(false);
const utils = api.useContext();
const router = useRouter();
const posthog = usePostHog();
const items = [
{
@ -42,7 +44,10 @@ export default function VideoMoreMenu({ video }: Props) {
a.download = video.title;
a.click();
});
//window.location.href = response.url;
});
posthog?.capture("download existing video", {
videoId: video.id,
});
},
},

View file

@ -4,10 +4,12 @@ const Recorder = dynamic(() => import("~/components/Recorder"), { ssr: false });
import dynamic from "next/dynamic";
import { useAtom } from "jotai";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import { usePostHog } from "posthog-js/react";
export default function VideoRecordModal() {
const [open, setOpen] = useAtom(recordVideoModalOpen);
const [step, setStep] = useState<"pre" | "in" | "post">("pre");
const posthog = usePostHog();
function closeModal() {
setOpen(false);
@ -15,6 +17,8 @@ export default function VideoRecordModal() {
const handleClose = () => {
if (step === "pre") closeModal();
posthog?.capture("cancel video pre-recording");
};
return (

View file

@ -5,6 +5,8 @@ import axios from "axios";
import { useRouter } from "next/router";
import { useAtom } from "jotai";
import uploadVideoModalOpen from "~/atoms/uploadVideoModalOpen";
import { usePostHog } from "posthog-js/react";
import { useSession } from "next-auth/react";
export default function VideoUploadModal() {
const [open, setOpen] = useAtom(uploadVideoModalOpen);
@ -14,6 +16,8 @@ export default function VideoUploadModal() {
const getSignedUrl = api.video.getUploadUrl.useMutation();
const apiUtils = api.useContext();
const videoRef = useRef<null | HTMLVideoElement>(null);
const posthog = usePostHog();
const { data: session } = useSession();
const handleFileChange = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
@ -33,6 +37,10 @@ export default function VideoUploadModal() {
function closeModal() {
setOpen(false);
posthog?.capture("cancel video upload", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
}
const handleSubmit = async (): Promise<void> => {

View file

@ -1,14 +1,15 @@
import { type AppType } from "next/app";
import { type Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { SessionProvider, useSession } from "next-auth/react";
import { api } from "~/utils/api";
import "~/styles/globals.css";
import CrispChat from "~/components/CrispChat";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { PostHogProvider, usePostHog } from "posthog-js/react";
import { env } from "~/env.mjs";
import { type ReactNode, useEffect } from "react";
// Check that PostHog is client-side (used to handle Next.js SSR)
if (typeof window !== "undefined") {
@ -28,11 +29,38 @@ const MyApp: AppType<{ session: Session | null }> = ({
return (
<SessionProvider session={session}>
<PostHogProvider client={posthog}>
<Component {...pageProps} />
<PostHogIdentificationWrapper>
<Component {...pageProps} />
</PostHogIdentificationWrapper>
<CrispChat />
</PostHogProvider>
</SessionProvider>
);
};
const PostHogIdentificationWrapper = ({
children,
}: {
children: ReactNode;
}) => {
const { data: session, status } = useSession();
const posthog = usePostHog();
useEffect(() => {
if (!posthog) return;
if (status === "authenticated") {
const { id, name, email, stripeSubscriptionStatus } = session?.user;
posthog.identify(id, {
name,
email,
stripeSubscriptionStatus,
});
} else if (status === "unauthenticated") {
posthog.reset();
}
}, [posthog, session, status]);
return <div>{children}</div>;
};
export default api.withTRPC(MyApp);

View file

@ -9,6 +9,7 @@ import {
handleSubscriptionCreatedOrUpdated,
} from "~/server/stripe-webhook-handlers";
import { stripe } from "~/server/stripe";
import { posthog } from "~/server/posthog";
// Stripe requires the raw body to construct the event.
export const config = {
@ -41,6 +42,7 @@ export default async function handler(
event,
stripe,
prisma,
posthog,
});
break;
case "customer.subscription.created":
@ -48,6 +50,7 @@ export default async function handler(
await handleSubscriptionCreatedOrUpdated({
event,
prisma,
posthog,
});
break;
case "customer.subscription.updated":
@ -55,6 +58,7 @@ export default async function handler(
await handleSubscriptionCreatedOrUpdated({
event,
prisma,
posthog,
});
break;
case "invoice.payment_failed":
@ -63,6 +67,17 @@ export default async function handler(
// Use this webhook to notify your user that their payment has
// failed and to retrieve new card details.
// Can also have Stripe send an email to the customer notifying them of the failure. See settings: https://dashboard.stripe.com/settings/billing/automatic
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata.userId;
if (userId) {
posthog.capture({
distinctId: userId,
event: "stripe invoice.payment_failed",
});
void posthog.shutdownAsync();
}
break;
case "customer.subscription.deleted":
// handle subscription cancelled automatically based
@ -70,6 +85,7 @@ export default async function handler(
await handleSubscriptionCanceled({
event,
prisma,
posthog,
});
break;
default:

View file

@ -11,17 +11,30 @@ import { ShareModal } from "~/components/ShareModal";
import { useSession } from "next-auth/react";
import VideoMoreMenu from "~/components/VideoMoreMenu";
import ProfileMenu from "~/components/ProfileMenu";
import { usePostHog } from "posthog-js/react";
const VideoList: NextPage = () => {
const router = useRouter();
const { status, data: session } = useSession();
const { videoId } = router.query as { videoId: string };
const posthog = usePostHog();
const { data: video, isLoading } = api.video.get.useQuery(
{ videoId },
{
enabled: router.isReady,
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (error?.data?.code === "FORBIDDEN") return false;
else return failureCount < 2;
},
onError: (err) => {
if (err?.data?.code === "FORBIDDEN") {
posthog?.capture("video page: FORBIDDEN");
} else if (err?.data?.code === "NOT_FOUND") {
posthog?.capture("video page: NOT_FOUND");
}
},
}
);
@ -33,7 +46,13 @@ const VideoList: NextPage = () => {
</span>
<span className="mt-3 max-w-[80%] text-center text-sm">
To create your own public recordings,{" "}
<Link href="/sign-in" className="pointer text-[#4169e1] underline">
<Link
onClick={() =>
posthog?.capture("click sign-up from video error page")
}
href="/sign-in"
className="pointer text-[#4169e1] underline"
>
create an account
</Link>{" "}
for free!
@ -78,6 +97,30 @@ const VideoList: NextPage = () => {
width="100%"
height="100%"
controls={true}
onPlay={() =>
posthog?.capture("play video", {
videoId: video.id,
videoCreatedAt: video.createdAt,
videoUpdatedAt: video.updatedAt,
videoUser: video.user.id,
videoSharing: video.sharing,
videoDeleteAfterLinkExpires:
video.delete_after_link_expires,
videoShareLinkExpiresAt: video.shareLinkExpiresAt,
})
}
onPause={() =>
posthog?.capture("pause video", {
videoId: video.id,
videoCreatedAt: video.createdAt,
videoUpdatedAt: video.updatedAt,
videoUser: video.user.id,
videoSharing: video.sharing,
videoDeleteAfterLinkExpires:
video.delete_after_link_expires,
videoShareLinkExpiresAt: video.shareLinkExpiresAt,
})
}
url={video.video_url}
/>
)}

View file

@ -16,6 +16,7 @@ import uploadVideoModalOpen from "~/atoms/uploadVideoModalOpen";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import Paywall from "~/components/Paywall";
import paywallAtom from "~/atoms/paywallAtom";
import { usePostHog } from "posthog-js/react";
const VideoList: NextPage = () => {
const [, setRecordOpen] = useAtom(recordVideoModalOpen);
@ -24,6 +25,7 @@ const VideoList: NextPage = () => {
const router = useRouter();
const { status, data: session } = useSession();
const { data: videos, isLoading } = api.video.getAll.useQuery();
const posthog = usePostHog();
if (status === "unauthenticated") {
void router.replace("/sign-in");
@ -31,13 +33,28 @@ const VideoList: NextPage = () => {
const openRecordModal = () => {
setRecordOpen(true);
posthog?.capture("open record video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
cta: "empty video list page",
});
};
const openUploadModal = () => {
if (session?.user.stripeSubscriptionStatus === "active") {
setUploadOpen(true);
posthog?.capture("open upload video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
cta: "empty video list page",
});
} else {
setPaywallOpen(true);
posthog?.capture("hit video upload paywall", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
cta: "empty video list page",
});
}
};

View file

@ -6,9 +6,64 @@ import { z } from "zod";
export const stripeRouter = createTRPCRouter({
createCheckoutSession: protectedProcedure
.input(z.object({ billedAnnually: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const { stripe, session, prisma, req } = ctx;
.mutation(
async ({ ctx: { prisma, stripe, session, req, posthog }, input }) => {
const customerId = await getOrCreateStripeCustomerIdForUser({
prisma,
stripe,
userId: session.user?.id,
});
if (!customerId) {
throw new Error("Could not create customer");
}
const baseUrl =
env.NODE_ENV === "development"
? `http://${req.headers.host ?? "localhost:3000"}`
: `https://${req.headers.host ?? env.NEXTAUTH_URL}`;
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
client_reference_id: session.user?.id,
payment_method_types: ["card"],
allow_promotion_codes: true,
mode: "subscription",
line_items: [
{
price: input.billedAnnually
? env.STRIPE_ANNUAL_PRICE_ID
: env.STRIPE_MONTHLY_PRICE_ID,
quantity: 1,
},
],
success_url: `${baseUrl}/videos?checkoutSuccess=true`,
cancel_url: `${baseUrl}/videos?checkoutCanceled=true`,
subscription_data: {
metadata: {
userId: session.user?.id,
},
},
});
if (!checkoutSession) {
throw new Error("Could not create checkout session");
}
posthog.capture({
distinctId: session.user.id,
event: "visiting checkout page",
properties: {
billingCycle: input.billedAnnually ? "annual" : "monthly",
},
});
void posthog.shutdownAsync();
return { checkoutUrl: checkoutSession.url };
}
),
createBillingPortalSession: protectedProcedure.mutation(
async ({ ctx: { stripe, session, prisma, req, posthog } }) => {
const customerId = await getOrCreateStripeCustomerIdForUser({
prisma,
stripe,
@ -24,63 +79,26 @@ export const stripeRouter = createTRPCRouter({
? `http://${req.headers.host ?? "localhost:3000"}`
: `https://${req.headers.host ?? env.NEXTAUTH_URL}`;
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
client_reference_id: session.user?.id,
payment_method_types: ["card"],
allow_promotion_codes: true,
mode: "subscription",
line_items: [
{
price: input.billedAnnually
? env.STRIPE_ANNUAL_PRICE_ID
: env.STRIPE_MONTHLY_PRICE_ID,
quantity: 1,
},
],
success_url: `${baseUrl}/videos?checkoutSuccess=true`,
cancel_url: `${baseUrl}/videos?checkoutCanceled=true`,
subscription_data: {
metadata: {
userId: session.user?.id,
},
},
});
const stripeBillingPortalSession =
await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${baseUrl}/videos`,
});
if (!checkoutSession) {
throw new Error("Could not create checkout session");
if (!stripeBillingPortalSession) {
throw new Error("Could not create billing portal session");
}
return { checkoutUrl: checkoutSession.url };
}),
createBillingPortalSession: protectedProcedure.mutation(async ({ ctx }) => {
const { stripe, session, prisma, req } = ctx;
const customerId = await getOrCreateStripeCustomerIdForUser({
prisma,
stripe,
userId: session.user?.id,
});
if (!customerId) {
throw new Error("Could not create customer");
}
const baseUrl =
env.NODE_ENV === "development"
? `http://${req.headers.host ?? "localhost:3000"}`
: `https://${req.headers.host ?? env.NEXTAUTH_URL}`;
const stripeBillingPortalSession =
await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${baseUrl}/videos`,
posthog.capture({
distinctId: session.user.id,
event: "visiting billing portal page",
properties: {
stripeSubscriptionStatus: session.user.stripeSubscriptionStatus,
},
});
void posthog.shutdownAsync();
if (!stripeBillingPortalSession) {
throw new Error("Could not create billing portal session");
return { billingPortalUrl: stripeBillingPortalSession.url };
}
return { billingPortalUrl: stripeBillingPortalSession.url };
}),
),
});

View file

@ -15,34 +15,45 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { TRPCError } from "@trpc/server";
export const videoRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => {
const videos = await ctx.prisma.video.findMany({
where: {
userId: ctx.session.user.id,
},
});
getAll: protectedProcedure.query(
async ({ ctx: { prisma, session, s3, posthog } }) => {
const videos = await prisma.video.findMany({
where: {
userId: session.user.id,
},
});
const videosWithThumbnailUrl = await Promise.all(
videos.map(async (video) => {
const thumbnailUrl = await getSignedUrl(
ctx.s3,
new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: video.userId + "/" + video.id + "-thumbnail",
})
);
posthog.capture({
distinctId: session.user.id,
event: "viewing video list",
properties: {
videoAmount: videos.length,
},
});
void posthog.shutdownAsync();
return { ...video, thumbnailUrl };
})
);
const videosWithThumbnailUrl = await Promise.all(
videos.map(async (video) => {
const thumbnailUrl = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: video.userId + "/" + video.id + "-thumbnail",
})
);
return videosWithThumbnailUrl;
}),
return { ...video, thumbnailUrl };
})
);
return videosWithThumbnailUrl;
}
),
get: publicProcedure
.input(z.object({ videoId: z.string() }))
.query(async ({ ctx, input }) => {
const { s3 } = ctx;
const video = await ctx.prisma.video.findUnique({
const { s3, posthog, session, prisma } = ctx;
const video = await prisma.video.findUnique({
where: {
id: input.videoId,
},
@ -54,10 +65,27 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" });
}
if (video.userId !== ctx?.session?.user.id && !video.sharing) {
if (video.userId !== session?.user.id && !video.sharing) {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (session) {
posthog.capture({
distinctId: session.user.id,
event: "viewing video",
properties: {
videoId: video.id,
videoCreatedAt: video.createdAt,
videoUpdatedAt: video.updatedAt,
videoUser: video.user.id,
videoSharing: video.sharing,
videoDeleteAfterLinkExpires: video.delete_after_link_expires,
videoShareLinkExpiresAt: video.shareLinkExpiresAt,
},
});
void posthog.shutdownAsync();
}
const getObjectCommand = new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: video.userId + "/" + video.id,
@ -69,20 +97,29 @@ export const videoRouter = createTRPCRouter({
}),
getUploadUrl: protectedProcedure
.input(z.object({ key: z.string() }))
.mutation(async ({ ctx, input }) => {
.mutation(async ({ ctx: { prisma, session, s3, posthog }, input }) => {
const { key } = input;
const { s3 } = ctx;
const videos = await ctx.prisma.video.findMany({
const videos = await prisma.video.findMany({
where: {
userId: ctx.session.user.id,
userId: session.user.id,
},
});
if (
videos.length >= 10 &&
ctx.session.user.stripeSubscriptionStatus !== "active"
session.user.stripeSubscriptionStatus !== "active"
) {
posthog.capture({
distinctId: session.user.id,
event: "hit video upload limit",
properties: {
videoAmount: videos.length,
stripeSubscriptionStatus: session.user.stripeSubscriptionStatus,
},
});
void posthog.shutdownAsync();
throw new TRPCError({
code: "FORBIDDEN",
message:
@ -90,9 +127,19 @@ export const videoRouter = createTRPCRouter({
});
}
const video = await ctx.prisma.video.create({
posthog.capture({
distinctId: session.user.id,
event: "uploading video",
properties: {
videoAmount: videos.length,
stripeSubscriptionStatus: session.user.stripeSubscriptionStatus,
},
});
void posthog.shutdownAsync();
const video = await prisma.video.create({
data: {
userId: ctx.session.user.id,
userId: session.user.id,
title: key,
},
});
@ -101,7 +148,7 @@ export const videoRouter = createTRPCRouter({
s3,
new PutObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: ctx.session.user.id + "/" + video.id,
Key: session.user.id + "/" + video.id,
})
);
@ -122,11 +169,11 @@ export const videoRouter = createTRPCRouter({
}),
setSharing: protectedProcedure
.input(z.object({ videoId: z.string(), sharing: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({
.mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await prisma.video.updateMany({
where: {
id: input.videoId,
userId: ctx.session.user.id,
userId: session.user.id,
},
data: {
sharing: input.sharing,
@ -137,6 +184,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" });
}
posthog.capture({
distinctId: session.user.id,
event: "update video setSharing",
properties: {
videoId: input.videoId,
videoSharing: input.sharing,
},
});
void posthog.shutdownAsync();
return {
success: true,
updateVideo,
@ -146,11 +203,11 @@ export const videoRouter = createTRPCRouter({
.input(
z.object({ videoId: z.string(), delete_after_link_expires: z.boolean() })
)
.mutation(async ({ ctx, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({
.mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await prisma.video.updateMany({
where: {
id: input.videoId,
userId: ctx.session.user.id,
userId: session.user.id,
},
data: {
delete_after_link_expires: input.delete_after_link_expires,
@ -161,6 +218,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" });
}
posthog.capture({
distinctId: session.user.id,
event: "update video delete_after_link_expires",
properties: {
videoId: input.videoId,
delete_after_link_expires: input.delete_after_link_expires,
},
});
void posthog.shutdownAsync();
return {
success: true,
updateVideo,
@ -173,11 +240,11 @@ export const videoRouter = createTRPCRouter({
shareLinkExpiresAt: z.nullable(z.date()),
})
)
.mutation(async ({ ctx, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({
.mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await prisma.video.updateMany({
where: {
id: input.videoId,
userId: ctx.session.user.id,
userId: session.user.id,
},
data: {
shareLinkExpiresAt: input.shareLinkExpiresAt,
@ -188,6 +255,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" });
}
posthog.capture({
distinctId: session.user.id,
event: "update video shareLinkExpiresAt",
properties: {
videoId: input.videoId,
shareLinkExpiresAt: input.shareLinkExpiresAt,
},
});
void posthog.shutdownAsync();
return {
success: true,
updateVideo,
@ -200,11 +277,11 @@ export const videoRouter = createTRPCRouter({
title: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({
.mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await prisma.video.updateMany({
where: {
id: input.videoId,
userId: ctx.session.user.id,
userId: session.user.id,
},
data: {
title: input.title,
@ -215,6 +292,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" });
}
posthog.capture({
distinctId: session.user.id,
event: "update video title",
properties: {
videoId: input.videoId,
title: input.title,
},
});
void posthog.shutdownAsync();
return {
success: true,
updateVideo,
@ -226,11 +313,11 @@ export const videoRouter = createTRPCRouter({
videoId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const deleteVideo = await ctx.prisma.video.deleteMany({
.mutation(async ({ ctx: { prisma, session, s3, posthog }, input }) => {
const deleteVideo = await prisma.video.deleteMany({
where: {
id: input.videoId,
userId: ctx.session.user.id,
userId: session.user.id,
},
});
@ -238,17 +325,26 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" });
}
const deleteVideoObject = await ctx.s3.send(
posthog.capture({
distinctId: session.user.id,
event: "video delete",
properties: {
videoId: input.videoId,
},
});
void posthog.shutdownAsync();
const deleteVideoObject = await s3.send(
new DeleteObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: ctx.session.user.id + "/" + input.videoId,
Key: session.user.id + "/" + input.videoId,
})
);
const deleteThumbnailObject = await ctx.s3.send(
const deleteThumbnailObject = await s3.send(
new DeleteObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: ctx.session.user.id + "/" + input.videoId + "-thumbnail",
Key: session.user.id + "/" + input.videoId + "-thumbnail",
})
);

View file

@ -41,6 +41,7 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => {
session: opts.session,
prisma,
s3,
posthog,
stripe,
req: opts.req,
res: opts.res,
@ -79,6 +80,7 @@ import { ZodError } from "zod";
import { s3 } from "~/server/aws/s3";
import { stripe } from "~/server/stripe";
import { type NextApiRequest, type NextApiResponse } from "next";
import { posthog } from "~/server/posthog";
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,

View file

@ -9,6 +9,7 @@ import GitHubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { env } from "~/env.mjs";
import { prisma } from "~/server/db";
import { PostHog } from "posthog-node";
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
@ -67,6 +68,44 @@ export const authOptions: NextAuthOptions = {
* @see https://next-auth.js.org/providers/github
*/
],
events: {
async signIn(message) {
const client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
});
client.capture({
distinctId: message.user.id,
event: "user logged in",
properties: {
provider: message.account?.provider,
isNewUser: message.isNewUser,
},
});
await client.shutdownAsync();
},
async signOut(message) {
const session = message.session as unknown as {
id: string;
sessionToken: string;
userId: string;
expires: Date;
};
if (!session?.userId) return;
const client = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
});
client.capture({
distinctId: session.userId,
event: "user logged out",
});
await client.shutdownAsync();
},
},
pages: {
signIn: "/sign-in",
},

6
src/server/posthog.ts Normal file
View file

@ -0,0 +1,6 @@
import { PostHog } from "posthog-node";
import { env } from "~/env.mjs";
export const posthog = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, {
host: env.NEXT_PUBLIC_POSTHOG_HOST,
});

View file

@ -1,5 +1,6 @@
import type { PrismaClient } from "@prisma/client";
import type Stripe from "stripe";
import { type PostHog } from "posthog-node";
// retrieves a Stripe customer id for a given user if it exists or creates a new one
export const getOrCreateStripeCustomerIdForUser = async ({
@ -54,10 +55,12 @@ export const handleInvoicePaid = async ({
event,
stripe,
prisma,
posthog,
}: {
event: Stripe.Event;
stripe: Stripe;
prisma: PrismaClient;
posthog: PostHog;
}) => {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription;
@ -76,14 +79,27 @@ export const handleInvoicePaid = async ({
stripeSubscriptionStatus: subscription.status,
},
});
if (userId && subscription.status) {
posthog.capture({
distinctId: userId,
event: "stripe invoice.paid",
properties: {
stripeSubscriptionStatus: subscription.status,
},
});
void posthog.shutdownAsync();
}
};
export const handleSubscriptionCreatedOrUpdated = async ({
event,
prisma,
posthog,
}: {
event: Stripe.Event;
prisma: PrismaClient;
posthog: PostHog;
}) => {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata.userId;
@ -98,14 +114,24 @@ export const handleSubscriptionCreatedOrUpdated = async ({
stripeSubscriptionStatus: subscription.status,
},
});
if (userId && subscription.status) {
posthog.capture({
distinctId: userId,
event: "stripe subscription created or updated",
});
void posthog.shutdownAsync();
}
};
export const handleSubscriptionCanceled = async ({
event,
prisma,
posthog,
}: {
event: Stripe.Event;
prisma: PrismaClient;
posthog: PostHog;
}) => {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata.userId;
@ -120,4 +146,12 @@ export const handleSubscriptionCanceled = async ({
stripeSubscriptionStatus: null,
},
});
if (userId && subscription.status) {
posthog.capture({
distinctId: userId,
event: "stripe subscription cancelled",
});
void posthog.shutdownAsync();
}
};