Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/next-js/15-app-router-saas/.posthog-events.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
[
{
"event_name": "user_signed_up",
"event_description": "User successfully completed the sign-up process and created an account",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "user_signed_in",
"event_description": "User successfully logged into their account",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "user_signed_out",
"event_description": "User signed out of their account",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "password_updated",
"event_description": "User successfully changed their password in security settings",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "account_deleted",
"event_description": "User deleted their account (churn event)",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "account_updated",
"event_description": "User updated their account information (name or email)",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "team_member_invited",
"event_description": "User invited a new team member to join their team",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "team_member_removed",
"event_description": "User removed a team member from the team",
"file_path": "app/(login)/actions.ts"
},
{
"event_name": "checkout_started",
"event_description": "User initiated the checkout process for a subscription plan (conversion funnel)",
"file_path": "lib/payments/actions.ts"
},
{
"event_name": "checkout_completed",
"event_description": "User successfully completed checkout and subscribed to a plan (conversion event)",
"file_path": "app/api/stripe/checkout/route.ts"
},
{
"event_name": "subscription_updated",
"event_description": "User's subscription was updated (upgrade, downgrade, or status change)",
"file_path": "app/api/stripe/webhook/route.ts"
},
{
"event_name": "subscription_cancelled",
"event_description": "User's subscription was cancelled (churn event)",
"file_path": "app/api/stripe/webhook/route.ts"
},
{
"event_name": "customer_portal_opened",
"event_description": "User clicked to manage their subscription in Stripe Customer Portal",
"file_path": "lib/payments/actions.ts"
},
{
"event_name": "invitation_accepted",
"event_description": "User accepted a team invitation during sign-up",
"file_path": "app/(login)/actions.ts"
}
]
122 changes: 122 additions & 0 deletions apps/next-js/15-app-router-saas/app/(login)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
validatedAction,
validatedActionWithUser
} from '@/lib/auth/middleware';
import { getPostHogClient } from '@/lib/posthog-server';

async function logActivity(
teamId: number | null | undefined,
Expand Down Expand Up @@ -91,6 +92,25 @@ export const signIn = validatedAction(signInSchema, async (data, formData) => {
logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN)
]);

// PostHog: Track user sign-in event
const posthog = getPostHogClient();
posthog.capture({
distinctId: email,
event: 'user_signed_in',
properties: {
email,
user_id: foundUser.id,
team_id: foundTeam?.id,
}
});
posthog.identify({
distinctId: email,
properties: {
email,
name: foundUser.name,
}
});

const redirectTo = formData.get('redirect') as string | null;
if (redirectTo === 'checkout') {
const priceId = formData.get('priceId') as string;
Expand Down Expand Up @@ -212,6 +232,37 @@ export const signUp = validatedAction(signUpSchema, async (data, formData) => {
setSession(createdUser)
]);

// PostHog: Track user sign-up event
const posthog = getPostHogClient();
posthog.capture({
distinctId: email,
event: 'user_signed_up',
properties: {
email,
user_id: createdUser.id,
team_id: teamId,
accepted_invitation: !!inviteId,
}
});
posthog.identify({
distinctId: email,
properties: {
email,
created_at: new Date().toISOString(),
}
});
if (inviteId) {
posthog.capture({
distinctId: email,
event: 'invitation_accepted',
properties: {
email,
team_id: teamId,
user_id: createdUser.id,
}
});
}

const redirectTo = formData.get('redirect') as string | null;
if (redirectTo === 'checkout') {
const priceId = formData.get('priceId') as string;
Expand All @@ -225,6 +276,18 @@ export async function signOut() {
const user = (await getUser()) as User;
const userWithTeam = await getUserWithTeam(user.id);
await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT);

// PostHog: Track user sign-out event
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'user_signed_out',
properties: {
user_id: user.id,
team_id: userWithTeam?.teamId,
}
});

(await cookies()).delete('session');
}

Expand Down Expand Up @@ -282,6 +345,17 @@ export const updatePassword = validatedActionWithUser(
logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_PASSWORD)
]);

// PostHog: Track password update event
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'password_updated',
properties: {
user_id: user.id,
team_id: userWithTeam?.teamId,
}
});

return {
success: 'Password updated successfully.'
};
Expand Down Expand Up @@ -313,6 +387,17 @@ export const deleteAccount = validatedActionWithUser(
ActivityType.DELETE_ACCOUNT
);

// PostHog: Track account deletion event (churn)
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'account_deleted',
properties: {
user_id: user.id,
team_id: userWithTeam?.teamId,
}
});

// Soft delete
await db
.update(users)
Expand Down Expand Up @@ -354,6 +439,18 @@ export const updateAccount = validatedActionWithUser(
logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_ACCOUNT)
]);

// PostHog: Track account update event
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'account_updated',
properties: {
user_id: user.id,
team_id: userWithTeam?.teamId,
updated_fields: ['name', 'email'],
}
});

return { name, success: 'Account updated successfully.' };
}
);
Expand Down Expand Up @@ -387,6 +484,18 @@ export const removeTeamMember = validatedActionWithUser(
ActivityType.REMOVE_TEAM_MEMBER
);

// PostHog: Track team member removal event
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'team_member_removed',
properties: {
user_id: user.id,
team_id: userWithTeam.teamId,
removed_member_id: memberId,
}
});

return { success: 'Team member removed successfully' };
}
);
Expand Down Expand Up @@ -451,6 +560,19 @@ export const inviteTeamMember = validatedActionWithUser(
ActivityType.INVITE_TEAM_MEMBER
);

// PostHog: Track team member invitation event
const posthog = getPostHogClient();
posthog.capture({
distinctId: user.email,
event: 'team_member_invited',
properties: {
user_id: user.id,
team_id: userWithTeam.teamId,
invited_email: email,
invited_role: role,
}
});

// TODO: Send invitation email and include ?inviteId={id} to sign-up URL
// await sendInvitationEmail(email, userWithTeam.team.name, role)

Expand Down
10 changes: 9 additions & 1 deletion apps/next-js/15-app-router-saas/app/(login)/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { CircleIcon, Loader2 } from 'lucide-react';
import { signIn, signUp } from './actions';
import { ActionState } from '@/lib/auth/middleware';
import posthog from 'posthog-js';

export function Login({ mode = 'signin' }: { mode?: 'signin' | 'signup' }) {
const searchParams = useSearchParams();
Expand All @@ -34,7 +35,14 @@ export function Login({ mode = 'signin' }: { mode?: 'signin' | 'signup' }) {
</div>

<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<form className="space-y-6" action={formAction}>
<form className="space-y-6" action={formAction} onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
if (email) {
// Identify user on form submission (before server action)
posthog.identify(email, { email });
}
}}>
<input type="hidden" name="redirect" value={redirect || ''} />
<input type="hidden" name="priceId" value={priceId || ''} />
<input type="hidden" name="inviteId" value={inviteId || ''} />
Expand Down
77 changes: 77 additions & 0 deletions apps/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Stripe from 'stripe';
import { handleSubscriptionChange, stripe } from '@/lib/payments/stripe';
import { NextRequest, NextResponse } from 'next/server';
import { getPostHogClient } from '@/lib/posthog-server';
import { db } from '@/lib/db/drizzle';
import { teams, teamMembers, users } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';

// Use a dummy webhook secret for stub mode
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_stub_secret';
Expand All @@ -26,6 +30,79 @@ export async function POST(request: NextRequest) {
case 'customer.subscription.deleted':
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionChange(subscription);

// PostHog: Track subscription events
const customerId = subscription.customer as string;
const team = await db
.select()
.from(teams)
.where(eq(teams.stripeCustomerId, customerId))
.limit(1);

if (team.length > 0) {
// Get the team owner's email for the distinct ID
const teamOwner = await db
.select({ user: users })
.from(teamMembers)
.innerJoin(users, eq(teamMembers.userId, users.id))
.where(eq(teamMembers.teamId, team[0].id))
.limit(1);

if (teamOwner.length > 0) {
const posthog = getPostHogClient();
const eventName = event.type === 'customer.subscription.deleted'
? 'subscription_cancelled'
: 'subscription_updated';

posthog.capture({
distinctId: teamOwner[0].user.email,
event: eventName,
properties: {
team_id: team[0].id,
subscription_id: subscription.id,
subscription_status: subscription.status,
price_id: subscription.items.data[0]?.price?.id,
cancel_at_period_end: subscription.cancel_at_period_end,
}
});
}
}
break;
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;

// PostHog: Track checkout completed event
if (session.customer) {
const checkoutCustomerId = session.customer as string;
const checkoutTeam = await db
.select()
.from(teams)
.where(eq(teams.stripeCustomerId, checkoutCustomerId))
.limit(1);

if (checkoutTeam.length > 0) {
const checkoutOwner = await db
.select({ user: users })
.from(teamMembers)
.innerJoin(users, eq(teamMembers.userId, users.id))
.where(eq(teamMembers.teamId, checkoutTeam[0].id))
.limit(1);

if (checkoutOwner.length > 0) {
const posthog = getPostHogClient();
posthog.capture({
distinctId: checkoutOwner[0].user.email,
event: 'checkout_completed',
properties: {
team_id: checkoutTeam[0].id,
session_id: session.id,
amount_total: session.amount_total,
currency: session.currency,
}
});
}
}
}
break;
default:
console.log(`Unhandled event type ${event.type}`);
Expand Down
8 changes: 8 additions & 0 deletions apps/next-js/15-app-router-saas/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import posthog from 'posthog-js'

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
defaults: '2025-05-24',
capture_exceptions: true,
debug: process.env.NODE_ENV === 'development',
});
Loading