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": "^10.0.1",
"micro-cors": "^0.1.1", "micro-cors": "^0.1.1",
"next": "^13.3.0", "next": "^13.3.0",
"next-auth": "^4.21.0", "next-auth": "^4.22.1",
"posthog-js": "^1.53.4", "posthog-js": "^1.53.4",
"posthog-node": "^3.1.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-media-recorder": "^1.6.6", "react-media-recorder": "^1.6.6",
@ -5349,9 +5350,9 @@
} }
}, },
"node_modules/next-auth": { "node_modules/next-auth": {
"version": "4.22.0", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.0.tgz", "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.1.tgz",
"integrity": "sha512-08+kjnDoE7aQ52O996x6cwA3ffc2CbHIkrCgLYhbE+aDIJBKI0oA9UbIEIe19/+ODYJgpAHHOtJx4izmsgaVag==", "integrity": "sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2", "@panva/hkdf": "^1.0.2",
@ -5858,6 +5859,26 @@
"rrweb-snapshot": "^1.1.14" "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": { "node_modules/preact": {
"version": "10.13.2", "version": "10.13.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz",
@ -11126,9 +11147,9 @@
} }
}, },
"next-auth": { "next-auth": {
"version": "4.22.0", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.0.tgz", "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.22.1.tgz",
"integrity": "sha512-08+kjnDoE7aQ52O996x6cwA3ffc2CbHIkrCgLYhbE+aDIJBKI0oA9UbIEIe19/+ODYJgpAHHOtJx4izmsgaVag==", "integrity": "sha512-NTR3f6W7/AWXKw8GSsgSyQcDW6jkslZLH8AiZa5PQ09w1kR8uHtR9rez/E9gAq/o17+p0JYHE8QjF3RoniiObA==",
"requires": { "requires": {
"@babel/runtime": "^7.20.13", "@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2", "@panva/hkdf": "^1.0.2",
@ -11451,6 +11472,25 @@
"rrweb-snapshot": "^1.1.14" "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": { "preact": {
"version": "10.13.2", "version": "10.13.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.13.2.tgz",

View file

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

View file

@ -5,22 +5,36 @@ import { useAtom } from "jotai";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen"; import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import paywallAtom from "~/atoms/paywallAtom"; import paywallAtom from "~/atoms/paywallAtom";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { usePostHog } from "posthog-js/react";
export default function NewVideoMenu() { export default function NewVideoMenu() {
const [, setRecordOpen] = useAtom(recordVideoModalOpen); const [, setRecordOpen] = useAtom(recordVideoModalOpen);
const [, setUploadOpen] = useAtom(uploadVideoModalOpen); const [, setUploadOpen] = useAtom(uploadVideoModalOpen);
const [, setPaywallOpen] = useAtom(paywallAtom); const [, setPaywallOpen] = useAtom(paywallAtom);
const { data: session } = useSession(); const { data: session } = useSession();
const posthog = usePostHog();
const openRecordModal = () => { const openRecordModal = () => {
setRecordOpen(true); setRecordOpen(true);
posthog?.capture("open record video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
}; };
const openUploadModal = () => { const openUploadModal = () => {
if (session?.user.stripeSubscriptionStatus === "active") { if (session?.user.stripeSubscriptionStatus === "active") {
setUploadOpen(true); setUploadOpen(true);
posthog?.capture("open upload video modal", {
stripeSubscriptionStatus: session?.user.stripeSubscriptionStatus,
});
} else { } else {
setPaywallOpen(true); 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 { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/20/solid"; import { CheckIcon } from "@heroicons/react/20/solid";
import Tooltip from "~/components/Tooltip"; import Tooltip from "~/components/Tooltip";
import { usePostHog } from "posthog-js/react";
export default function Paywall() { export default function Paywall() {
const { mutateAsync: createCheckoutSession } = const { mutateAsync: createCheckoutSession } =
@ -14,9 +15,12 @@ export default function Paywall() {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useAtom(paywallAtom); const [open, setOpen] = useAtom(paywallAtom);
const [billedAnnually, setBilledAnnually] = useState<boolean>(false); const [billedAnnually, setBilledAnnually] = useState<boolean>(false);
const posthog = usePostHog();
function closeModal() { function closeModal() {
setOpen(false); setOpen(false);
posthog?.capture("close paywall");
} }
const handleCheckout = async () => { const handleCheckout = async () => {
@ -26,6 +30,12 @@ export default function Paywall() {
} }
}; };
const toggleBillingCycle = () => {
setBilledAnnually(!billedAnnually);
posthog?.capture("change billing cycle");
};
return ( return (
<Transition appear show={open} as={Fragment}> <Transition appear show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
@ -85,7 +95,7 @@ export default function Paywall() {
<span className=" svelte-10wstod">Monthly</span> <span className=" svelte-10wstod">Monthly</span>
<label className="group relative flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-600"> <label className="group relative flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-600">
<input <input
onChange={() => setBilledAnnually(!billedAnnually)} onChange={toggleBillingCycle}
checked={billedAnnually} checked={billedAnnually}
type="checkbox" type="checkbox"
className="peer absolute left-1/2 hidden h-full w-full -translate-x-1/2 rounded-md" 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 { signOut, useSession } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { usePostHog } from "posthog-js/react";
export default function ProfileMenu() { export default function ProfileMenu() {
const { mutateAsync: createBillingPortalSession } = const { mutateAsync: createBillingPortalSession } =
api.stripe.createBillingPortalSession.useMutation(); api.stripe.createBillingPortalSession.useMutation();
const { push } = useRouter(); const { push } = useRouter();
const { data: session } = useSession(); 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 ( return (
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
@ -34,15 +48,7 @@ export default function ProfileMenu() {
<Menu.Item> <Menu.Item>
{({ active }) => ( {({ active }) => (
<div <div
onClick={() => { onClick={openBillingSettings}
void createBillingPortalSession().then(
({ billingPortalUrl }) => {
if (billingPortalUrl) {
void push(billingPortalUrl);
}
}
);
}}
className={`mx-2 flex h-8 w-40 cursor-pointer flex-row content-center rounded-md p-2 ${ className={`mx-2 flex h-8 w-40 cursor-pointer flex-row content-center rounded-md p-2 ${
active ? "bg-gray-100" : "" active ? "bg-gray-100" : ""
}`} }`}

View file

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

View file

@ -3,6 +3,7 @@ import { Fragment, useState } from "react";
import { ModernSwitch } from "~/components/ModernSwitch"; import { ModernSwitch } from "~/components/ModernSwitch";
import { api, type RouterOutputs } from "~/utils/api"; import { api, type RouterOutputs } from "~/utils/api";
import ExpireDateSelectMenu from "~/components/ExpireDateSelectMenu"; import ExpireDateSelectMenu from "~/components/ExpireDateSelectMenu";
import { usePostHog } from "posthog-js/react";
interface Props { interface Props {
video: RouterOutputs["video"]["get"]; video: RouterOutputs["video"]["get"];
@ -11,6 +12,23 @@ interface Props {
export function ShareModal({ video }: Props) { export function ShareModal({ video }: Props) {
const utils = api.useContext(); const utils = api.useContext();
const [open, setOpen] = useState<boolean>(false); 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({ const setSharingMutation = api.video.setSharing.useMutation({
onMutate: async ({ videoId, sharing }) => { onMutate: async ({ videoId, sharing }) => {
@ -58,23 +76,24 @@ export function ShareModal({ video }: Props) {
setTimeout(() => { setTimeout(() => {
setLinkCopied(false); setLinkCopied(false);
}, 5000); }, 5000);
posthog?.capture("public video link copied", {
videoSharing: video.sharing,
videoId: video.id,
});
}; };
return ( return (
<> <>
<span <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]" className="ml-4 cursor-pointer rounded border border-[#0000001a] px-2 py-2 text-sm text-[#292d34] hover:bg-[#fafbfc]"
> >
Share Share
</span> </span>
<Transition appear show={open} as={Fragment}> <Transition appear show={open} as={Fragment}>
<Dialog <Dialog as="div" className="relative z-10" onClose={closeModal}>
as="div"
className="relative z-10"
onClose={() => setOpen(false)}
>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -146,19 +165,6 @@ export function ShareModal({ video }: Props) {
} }
/> />
</div> </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> </div>
</> </>
) : null} ) : null}

View file

@ -8,6 +8,7 @@ import {
TrashIcon, TrashIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { usePostHog } from "posthog-js/react";
interface Props { interface Props {
video: RouterOutputs["video"]["get"]; video: RouterOutputs["video"]["get"];
@ -18,6 +19,7 @@ export default function VideoMoreMenu({ video }: Props) {
const [renameMenuOpen, setRenameMenuOpen] = useState<boolean>(false); const [renameMenuOpen, setRenameMenuOpen] = useState<boolean>(false);
const utils = api.useContext(); const utils = api.useContext();
const router = useRouter(); const router = useRouter();
const posthog = usePostHog();
const items = [ const items = [
{ {
@ -42,7 +44,10 @@ export default function VideoMoreMenu({ video }: Props) {
a.download = video.title; a.download = video.title;
a.click(); 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 dynamic from "next/dynamic";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import recordVideoModalOpen from "~/atoms/recordVideoModalOpen"; import recordVideoModalOpen from "~/atoms/recordVideoModalOpen";
import { usePostHog } from "posthog-js/react";
export default function VideoRecordModal() { export default function VideoRecordModal() {
const [open, setOpen] = useAtom(recordVideoModalOpen); const [open, setOpen] = useAtom(recordVideoModalOpen);
const [step, setStep] = useState<"pre" | "in" | "post">("pre"); const [step, setStep] = useState<"pre" | "in" | "post">("pre");
const posthog = usePostHog();
function closeModal() { function closeModal() {
setOpen(false); setOpen(false);
@ -15,6 +17,8 @@ export default function VideoRecordModal() {
const handleClose = () => { const handleClose = () => {
if (step === "pre") closeModal(); if (step === "pre") closeModal();
posthog?.capture("cancel video pre-recording");
}; };
return ( return (

View file

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

View file

@ -1,14 +1,15 @@
import { type AppType } from "next/app"; import { type AppType } from "next/app";
import { type Session } from "next-auth"; 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 { api } from "~/utils/api";
import "~/styles/globals.css"; import "~/styles/globals.css";
import CrispChat from "~/components/CrispChat"; import CrispChat from "~/components/CrispChat";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react"; import { PostHogProvider, usePostHog } from "posthog-js/react";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
import { type ReactNode, useEffect } from "react";
// Check that PostHog is client-side (used to handle Next.js SSR) // Check that PostHog is client-side (used to handle Next.js SSR)
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -28,11 +29,38 @@ const MyApp: AppType<{ session: Session | null }> = ({
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<PostHogIdentificationWrapper>
<Component {...pageProps} /> <Component {...pageProps} />
</PostHogIdentificationWrapper>
<CrispChat /> <CrispChat />
</PostHogProvider> </PostHogProvider>
</SessionProvider> </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); export default api.withTRPC(MyApp);

View file

@ -9,6 +9,7 @@ import {
handleSubscriptionCreatedOrUpdated, handleSubscriptionCreatedOrUpdated,
} from "~/server/stripe-webhook-handlers"; } from "~/server/stripe-webhook-handlers";
import { stripe } from "~/server/stripe"; import { stripe } from "~/server/stripe";
import { posthog } from "~/server/posthog";
// Stripe requires the raw body to construct the event. // Stripe requires the raw body to construct the event.
export const config = { export const config = {
@ -41,6 +42,7 @@ export default async function handler(
event, event,
stripe, stripe,
prisma, prisma,
posthog,
}); });
break; break;
case "customer.subscription.created": case "customer.subscription.created":
@ -48,6 +50,7 @@ export default async function handler(
await handleSubscriptionCreatedOrUpdated({ await handleSubscriptionCreatedOrUpdated({
event, event,
prisma, prisma,
posthog,
}); });
break; break;
case "customer.subscription.updated": case "customer.subscription.updated":
@ -55,6 +58,7 @@ export default async function handler(
await handleSubscriptionCreatedOrUpdated({ await handleSubscriptionCreatedOrUpdated({
event, event,
prisma, prisma,
posthog,
}); });
break; break;
case "invoice.payment_failed": case "invoice.payment_failed":
@ -63,6 +67,17 @@ export default async function handler(
// Use this webhook to notify your user that their payment has // Use this webhook to notify your user that their payment has
// failed and to retrieve new card details. // 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 // 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; break;
case "customer.subscription.deleted": case "customer.subscription.deleted":
// handle subscription cancelled automatically based // handle subscription cancelled automatically based
@ -70,6 +85,7 @@ export default async function handler(
await handleSubscriptionCanceled({ await handleSubscriptionCanceled({
event, event,
prisma, prisma,
posthog,
}); });
break; break;
default: default:

View file

@ -11,17 +11,30 @@ import { ShareModal } from "~/components/ShareModal";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import VideoMoreMenu from "~/components/VideoMoreMenu"; import VideoMoreMenu from "~/components/VideoMoreMenu";
import ProfileMenu from "~/components/ProfileMenu"; import ProfileMenu from "~/components/ProfileMenu";
import { usePostHog } from "posthog-js/react";
const VideoList: NextPage = () => { const VideoList: NextPage = () => {
const router = useRouter(); const router = useRouter();
const { status, data: session } = useSession(); const { status, data: session } = useSession();
const { videoId } = router.query as { videoId: string }; const { videoId } = router.query as { videoId: string };
const posthog = usePostHog();
const { data: video, isLoading } = api.video.get.useQuery( const { data: video, isLoading } = api.video.get.useQuery(
{ videoId }, { videoId },
{ {
enabled: router.isReady, enabled: router.isReady,
refetchOnWindowFocus: false, 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>
<span className="mt-3 max-w-[80%] text-center text-sm"> <span className="mt-3 max-w-[80%] text-center text-sm">
To create your own public recordings,{" "} 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 create an account
</Link>{" "} </Link>{" "}
for free! for free!
@ -78,6 +97,30 @@ const VideoList: NextPage = () => {
width="100%" width="100%"
height="100%" height="100%"
controls={true} 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} url={video.video_url}
/> />
)} )}

View file

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

View file

@ -6,9 +6,8 @@ import { z } from "zod";
export const stripeRouter = createTRPCRouter({ export const stripeRouter = createTRPCRouter({
createCheckoutSession: protectedProcedure createCheckoutSession: protectedProcedure
.input(z.object({ billedAnnually: z.boolean() })) .input(z.object({ billedAnnually: z.boolean() }))
.mutation(async ({ ctx, input }) => { .mutation(
const { stripe, session, prisma, req } = ctx; async ({ ctx: { prisma, stripe, session, req, posthog }, input }) => {
const customerId = await getOrCreateStripeCustomerIdForUser({ const customerId = await getOrCreateStripeCustomerIdForUser({
prisma, prisma,
stripe, stripe,
@ -51,11 +50,20 @@ export const stripeRouter = createTRPCRouter({
throw new Error("Could not create checkout session"); throw new Error("Could not create checkout session");
} }
return { checkoutUrl: checkoutSession.url }; posthog.capture({
}), distinctId: session.user.id,
createBillingPortalSession: protectedProcedure.mutation(async ({ ctx }) => { event: "visiting checkout page",
const { stripe, session, prisma, req } = ctx; 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({ const customerId = await getOrCreateStripeCustomerIdForUser({
prisma, prisma,
stripe, stripe,
@ -81,6 +89,16 @@ export const stripeRouter = createTRPCRouter({
throw new Error("Could not create billing portal session"); throw new Error("Could not create billing portal session");
} }
return { billingPortalUrl: stripeBillingPortalSession.url }; posthog.capture({
}), distinctId: session.user.id,
event: "visiting billing portal page",
properties: {
stripeSubscriptionStatus: session.user.stripeSubscriptionStatus,
},
});
void posthog.shutdownAsync();
return { billingPortalUrl: stripeBillingPortalSession.url };
}
),
}); });

View file

@ -15,17 +15,27 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
export const videoRouter = createTRPCRouter({ export const videoRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ ctx }) => { getAll: protectedProcedure.query(
const videos = await ctx.prisma.video.findMany({ async ({ ctx: { prisma, session, s3, posthog } }) => {
const videos = await prisma.video.findMany({
where: { where: {
userId: ctx.session.user.id, userId: session.user.id,
}, },
}); });
posthog.capture({
distinctId: session.user.id,
event: "viewing video list",
properties: {
videoAmount: videos.length,
},
});
void posthog.shutdownAsync();
const videosWithThumbnailUrl = await Promise.all( const videosWithThumbnailUrl = await Promise.all(
videos.map(async (video) => { videos.map(async (video) => {
const thumbnailUrl = await getSignedUrl( const thumbnailUrl = await getSignedUrl(
ctx.s3, s3,
new GetObjectCommand({ new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME, Bucket: env.AWS_BUCKET_NAME,
Key: video.userId + "/" + video.id + "-thumbnail", Key: video.userId + "/" + video.id + "-thumbnail",
@ -37,12 +47,13 @@ export const videoRouter = createTRPCRouter({
); );
return videosWithThumbnailUrl; return videosWithThumbnailUrl;
}), }
),
get: publicProcedure get: publicProcedure
.input(z.object({ videoId: z.string() })) .input(z.object({ videoId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { s3 } = ctx; const { s3, posthog, session, prisma } = ctx;
const video = await ctx.prisma.video.findUnique({ const video = await prisma.video.findUnique({
where: { where: {
id: input.videoId, id: input.videoId,
}, },
@ -54,10 +65,27 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "NOT_FOUND" }); 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" }); 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({ const getObjectCommand = new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME, Bucket: env.AWS_BUCKET_NAME,
Key: video.userId + "/" + video.id, Key: video.userId + "/" + video.id,
@ -69,20 +97,29 @@ export const videoRouter = createTRPCRouter({
}), }),
getUploadUrl: protectedProcedure getUploadUrl: protectedProcedure
.input(z.object({ key: z.string() })) .input(z.object({ key: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, s3, posthog }, input }) => {
const { key } = input; const { key } = input;
const { s3 } = ctx;
const videos = await ctx.prisma.video.findMany({ const videos = await prisma.video.findMany({
where: { where: {
userId: ctx.session.user.id, userId: session.user.id,
}, },
}); });
if ( if (
videos.length >= 10 && 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({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: 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: { data: {
userId: ctx.session.user.id, userId: session.user.id,
title: key, title: key,
}, },
}); });
@ -101,7 +148,7 @@ export const videoRouter = createTRPCRouter({
s3, s3,
new PutObjectCommand({ new PutObjectCommand({
Bucket: env.AWS_BUCKET_NAME, 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 setSharing: protectedProcedure
.input(z.object({ videoId: z.string(), sharing: z.boolean() })) .input(z.object({ videoId: z.string(), sharing: z.boolean() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({ const updateVideo = await prisma.video.updateMany({
where: { where: {
id: input.videoId, id: input.videoId,
userId: ctx.session.user.id, userId: session.user.id,
}, },
data: { data: {
sharing: input.sharing, sharing: input.sharing,
@ -137,6 +184,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" }); 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 { return {
success: true, success: true,
updateVideo, updateVideo,
@ -146,11 +203,11 @@ export const videoRouter = createTRPCRouter({
.input( .input(
z.object({ videoId: z.string(), delete_after_link_expires: z.boolean() }) z.object({ videoId: z.string(), delete_after_link_expires: z.boolean() })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({ const updateVideo = await prisma.video.updateMany({
where: { where: {
id: input.videoId, id: input.videoId,
userId: ctx.session.user.id, userId: session.user.id,
}, },
data: { data: {
delete_after_link_expires: input.delete_after_link_expires, delete_after_link_expires: input.delete_after_link_expires,
@ -161,6 +218,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" }); 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 { return {
success: true, success: true,
updateVideo, updateVideo,
@ -173,11 +240,11 @@ export const videoRouter = createTRPCRouter({
shareLinkExpiresAt: z.nullable(z.date()), shareLinkExpiresAt: z.nullable(z.date()),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({ const updateVideo = await prisma.video.updateMany({
where: { where: {
id: input.videoId, id: input.videoId,
userId: ctx.session.user.id, userId: session.user.id,
}, },
data: { data: {
shareLinkExpiresAt: input.shareLinkExpiresAt, shareLinkExpiresAt: input.shareLinkExpiresAt,
@ -188,6 +255,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" }); 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 { return {
success: true, success: true,
updateVideo, updateVideo,
@ -200,11 +277,11 @@ export const videoRouter = createTRPCRouter({
title: z.string(), title: z.string(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, posthog }, input }) => {
const updateVideo = await ctx.prisma.video.updateMany({ const updateVideo = await prisma.video.updateMany({
where: { where: {
id: input.videoId, id: input.videoId,
userId: ctx.session.user.id, userId: session.user.id,
}, },
data: { data: {
title: input.title, title: input.title,
@ -215,6 +292,16 @@ export const videoRouter = createTRPCRouter({
throw new TRPCError({ code: "FORBIDDEN" }); 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 { return {
success: true, success: true,
updateVideo, updateVideo,
@ -226,11 +313,11 @@ export const videoRouter = createTRPCRouter({
videoId: z.string(), videoId: z.string(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx: { prisma, session, s3, posthog }, input }) => {
const deleteVideo = await ctx.prisma.video.deleteMany({ const deleteVideo = await prisma.video.deleteMany({
where: { where: {
id: input.videoId, 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" }); 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({ new DeleteObjectCommand({
Bucket: env.AWS_BUCKET_NAME, 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({ new DeleteObjectCommand({
Bucket: env.AWS_BUCKET_NAME, 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, session: opts.session,
prisma, prisma,
s3, s3,
posthog,
stripe, stripe,
req: opts.req, req: opts.req,
res: opts.res, res: opts.res,
@ -79,6 +80,7 @@ import { ZodError } from "zod";
import { s3 } from "~/server/aws/s3"; import { s3 } from "~/server/aws/s3";
import { stripe } from "~/server/stripe"; import { stripe } from "~/server/stripe";
import { type NextApiRequest, type NextApiResponse } from "next"; import { type NextApiRequest, type NextApiResponse } from "next";
import { posthog } from "~/server/posthog";
const t = initTRPC.context<typeof createTRPCContext>().create({ const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson, transformer: superjson,

View file

@ -9,6 +9,7 @@ import GitHubProvider from "next-auth/providers/github";
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
import { prisma } from "~/server/db"; 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` * 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 * @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: { pages: {
signIn: "/sign-in", 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 { PrismaClient } from "@prisma/client";
import type Stripe from "stripe"; 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 // retrieves a Stripe customer id for a given user if it exists or creates a new one
export const getOrCreateStripeCustomerIdForUser = async ({ export const getOrCreateStripeCustomerIdForUser = async ({
@ -54,10 +55,12 @@ export const handleInvoicePaid = async ({
event, event,
stripe, stripe,
prisma, prisma,
posthog,
}: { }: {
event: Stripe.Event; event: Stripe.Event;
stripe: Stripe; stripe: Stripe;
prisma: PrismaClient; prisma: PrismaClient;
posthog: PostHog;
}) => { }) => {
const invoice = event.data.object as Stripe.Invoice; const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = invoice.subscription; const subscriptionId = invoice.subscription;
@ -76,14 +79,27 @@ export const handleInvoicePaid = async ({
stripeSubscriptionStatus: subscription.status, 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 ({ export const handleSubscriptionCreatedOrUpdated = async ({
event, event,
prisma, prisma,
posthog,
}: { }: {
event: Stripe.Event; event: Stripe.Event;
prisma: PrismaClient; prisma: PrismaClient;
posthog: PostHog;
}) => { }) => {
const subscription = event.data.object as Stripe.Subscription; const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata.userId; const userId = subscription.metadata.userId;
@ -98,14 +114,24 @@ export const handleSubscriptionCreatedOrUpdated = async ({
stripeSubscriptionStatus: subscription.status, stripeSubscriptionStatus: subscription.status,
}, },
}); });
if (userId && subscription.status) {
posthog.capture({
distinctId: userId,
event: "stripe subscription created or updated",
});
void posthog.shutdownAsync();
}
}; };
export const handleSubscriptionCanceled = async ({ export const handleSubscriptionCanceled = async ({
event, event,
prisma, prisma,
posthog,
}: { }: {
event: Stripe.Event; event: Stripe.Event;
prisma: PrismaClient; prisma: PrismaClient;
posthog: PostHog;
}) => { }) => {
const subscription = event.data.object as Stripe.Subscription; const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata.userId; const userId = subscription.metadata.userId;
@ -120,4 +146,12 @@ export const handleSubscriptionCanceled = async ({
stripeSubscriptionStatus: null, stripeSubscriptionStatus: null,
}, },
}); });
if (userId && subscription.status) {
posthog.capture({
distinctId: userId,
event: "stripe subscription cancelled",
});
void posthog.shutdownAsync();
}
}; };