Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2b5960f
Freeze billing and bot columns in the profile guard.
builtbyd3v Sep 30, 2026
15e645c
Track portfolio shares by method.
builtbyd3v Sep 30, 2026
3340ac9
Apply search privacy rules to suggested profiles and clamp the limit.
builtbyd3v Sep 30, 2026
091c507
Track which portfolio sections a student publishes.
builtbyd3v Sep 30, 2026
dba704d
Track new comments with the label of the post they answer.
builtbyd3v Sep 30, 2026
7fad9ad
Require group members to follow the adder and block re-adding people …
builtbyd3v Sep 30, 2026
f1fdad8
Track which search facets people filter by.
builtbyd3v Sep 30, 2026
9e7a00a
Track new group chats and their size.
builtbyd3v Sep 30, 2026
de1c1d5
Freeze hidden_by_suspension in the post update guard.
builtbyd3v Sep 30, 2026
5771269
Track finished onboarding with the steps a student completed.
builtbyd3v Sep 30, 2026
c3e0e9e
Offer only followers as group members to match the database rule.
builtbyd3v Sep 30, 2026
44e6102
Test the analytics helpers for sections, facets, and onboarding steps.
builtbyd3v Sep 30, 2026
74a71e5
Add rls_test assertions for the profile guard, suggestions, group con…
builtbyd3v Sep 30, 2026
ba7c97b
Merge branch 'advisor/003-portfolio-and-help-funnel-analytics' into a…
builtbyd3v Sep 30, 2026
d7b286a
Add headline and profile links to profiles.
builtbyd3v Sep 30, 2026
bd684bf
Type the headline and profile link columns.
builtbyd3v Sep 30, 2026
661942d
Test that headline and links follow public profile privacy.
builtbyd3v Sep 30, 2026
3ca01d4
Derive portfolio skills from published projects.
builtbyd3v Sep 30, 2026
250a0b2
Validate the headline and profile links on the server.
builtbyd3v Sep 30, 2026
a6aae7c
Save the headline and profile links from the edit form.
builtbyd3v Sep 30, 2026
127b4ed
Add headline and link inputs to the edit profile form.
builtbyd3v Sep 30, 2026
a94485e
Show the headline and profile links on the portfolio, and nudge owner…
builtbyd3v Sep 30, 2026
81341e3
Ask for portfolio publish consent as the last onboarding step.
builtbyd3v Sep 30, 2026
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
21 changes: 14 additions & 7 deletions app/(app)/messages/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { TEXT_LIMITS } from "@/lib/utils/validation";
import { getPostHogServerClient } from "@/lib/posthog-server";

export async function startDmWithUsername(username: string) {
const supabase = await createClient();
Expand Down Expand Up @@ -37,10 +38,10 @@ export type MessageUserResult = {
is_pro: boolean;
};

// Candidates for the "New group" picker: accounts the viewer follows
// (accepted), minus anyone blocked either direction. Followed-only is a UI
// choice, not an RLS/RPC requirement -- create_group_conversation itself only
// hard-rejects a blocked creator<->member pair (see plan 025 NOTES).
// Candidates for the "New group" picker: accounts that follow the viewer
// (accepted), minus anyone blocked either direction. Mirrors the RPC rule:
// create_group_conversation and add_group_member only accept members who
// follow the caller.
export async function listFollowedForGroup(): Promise<MessageUserResult[]> {
const supabase = await createClient();
const {
Expand All @@ -52,14 +53,14 @@ export async function listFollowedForGroup(): Promise<MessageUserResult[]> {
supabase.rpc("get_blocked_ids"),
supabase
.from("follows")
.select("following:profiles!follows_following_id_fkey(id, username, display_name, avatar_url, is_pro)")
.eq("follower_id", user.id)
.select("follower:profiles!follows_follower_id_fkey(id, username, display_name, avatar_url, is_pro)")
.eq("following_id", user.id)
.eq("status", "accepted"),
]);

const blockedSet = new Set((blocked ?? []) as string[]);
return (rows ?? [])
.map((r) => (Array.isArray(r.following) ? r.following[0] : r.following))
.map((r) => (Array.isArray(r.follower) ? r.follower[0] : r.follower))
.filter((p): p is MessageUserResult => !!p && !blockedSet.has(p.id));
}

Expand All @@ -81,6 +82,12 @@ export async function createGroupConversation(title: string, memberIds: string[]
return { error: error?.message ?? "Could not create group" };
}

getPostHogServerClient()?.capture({
distinctId: user.id,
event: "group_created",
properties: { size: new Set([user.id, ...memberIds]).size },
});

return { conversationId };
}

Expand Down
43 changes: 42 additions & 1 deletion app/(app)/onboarding/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { updateProfile, type EditState } from "@/app/(app)/profile/edit/actions";
import { parseStepsDone } from "@/lib/onboarding";
import { getPostHogServerClient } from "@/lib/posthog-server";
import { getOwnerSettings, parsePublishFlags, saveOwnerSettings } from "@/lib/portfolio/owner";

function isRedirectSignal(err: unknown): boolean {
return (
Expand All @@ -23,13 +26,51 @@ export async function saveOnboardingBasics(prev: EditState, formData: FormData):
}
}

export async function finishOnboarding(): Promise<void> {
// Final onboarding step. Consent only: runs when the student leaves the
// pre-checked box on. Turns on intro, projects, experience, education and
// keeps every other flag and the section order the student already has
// (onboarding is revisitable from the feed checklist).
export async function savePortfolioConsent(_prev: EditState, formData: FormData): Promise<EditState> {
if (formData.get("publish_portfolio") !== "on") return {};
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return { error: "You must be logged in." };
const current = await getOwnerSettings(supabase, user.id);
if (!current.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." };
const flags = parsePublishFlags({
publish_intro: true,
publish_projects: true,
publish_experience: true,
publish_education: true,
publish_activity: current.data.publish_activity,
publish_posts: current.data.publish_posts,
allow_indexing: current.data.allow_indexing,
});
if (!flags.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." };
const { data: profile } = await supabase.from("profiles").select("is_private").eq("id", user.id).maybeSingle();
// sectionOrder undefined keeps the saved order, so isPro is never consulted.
const saved = await saveOwnerSettings(supabase, user.id, flags.data, undefined, {
isPrivate: profile?.is_private ?? false,
isPro: false,
});
if (!saved.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." };
return {};
}

export async function finishOnboarding(stepsDoneRaw: unknown = []): Promise<void> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");

await supabase.from("profiles").update({ onboarded_at: new Date().toISOString() }).eq("id", user.id);
getPostHogServerClient()?.capture({
distinctId: user.id,
event: "onboarding_completed",
properties: { steps_done: parseStepsDone(stepsDoneRaw) },
});
redirect("/feed");
}
16 changes: 16 additions & 0 deletions app/(app)/post/[id]/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
import { getPostHogServerClient } from "@/lib/posthog-server";
import { TEXT_LIMITS, textLimitError } from "@/lib/utils/validation";

export type CommentState = { error?: string; ok?: boolean };
Expand Down Expand Up @@ -33,6 +34,21 @@ export async function createComment(_prev: CommentState, formData: FormData): Pr
const { error } = await supabase.from("comments").insert({ post_id: postId, user_id: user.id, content });
if (error) return { error: "Could not post your comment. Try again." };

// analytics only: never break the comment action
try {
const posthog = getPostHogServerClient();
if (posthog) {
const { data: post } = await supabase.from("posts").select("context_label").eq("id", postId).maybeSingle();
posthog.capture({
distinctId: user.id,
event: "comment_created",
properties: { on_label: post?.context_label ?? null },
});
}
} catch {
// ignore
}

revalidatePath(`/post/${postId}`);
return { ok: true };
}
Expand Down
33 changes: 32 additions & 1 deletion app/(app)/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import TrackPortfolioView from "@/components/portfolio/TrackPortfolioView";
import { OwnerAnalyticsSection, PortfolioAnalyticsFallback } from "@/components/portfolio/PortfolioAnalytics";
import SharePortfolioButton from "@/components/portfolio/SharePortfolioButton";
import PortfolioBanner from "@/components/portfolio/PortfolioBanner";
import ResumeLinks from "@/components/portfolio/ResumeLinks";
import UnavailableNotice from "@/components/portfolio/UnavailableNotice";
import {
ActivitySection,
Expand All @@ -44,7 +45,7 @@ import {
PublicProjectList,
} from "@/components/portfolio/ProfileSections";
const PROFILE_SELECT =
"id, username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot";
"id, username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot, headline, github_url, linkedin_url, website_url";
const PROFILE_SELECT_FALLBACK =
"id, username, display_name, avatar_url, banner_url, year, major, bio, goals, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot";

Expand Down Expand Up @@ -399,7 +400,9 @@ async function PublicProfileView({ username }: { username: string }) {
<UserBadges isPro={profile.is_pro} isFounder={profile.is_founder} isCampusFounder={profile.is_campus_founder} isVerifiedStudent={profile.verified_student} isBot={profile.is_bot} />
</div>
<p className="mt-0.5 text-[15px] text-[var(--ink-muted)]">@{profile.username}</p>
{profile.headline && <p className="mt-2 text-[15px] text-[var(--ink)]">{profile.headline}</p>}
{metaLine && <p className="mt-2 text-sm text-[var(--ink-muted)]">{metaLine}</p>}
<ResumeLinks github={profile.github_url} linkedin={profile.linkedin_url} website={profile.website_url} />
<div className="profile-stat-enter mt-4 flex flex-wrap gap-x-6 gap-y-1">
<Stat value={Number(counts.posts)} label="posts" accent={Boolean(accentColor)} />
<Stat value={Number(counts.followers)} label="followers" accent={Boolean(accentColor)} href={`/profile/${profile.username}/followers`} />
Expand Down Expand Up @@ -496,6 +499,19 @@ export default async function ProfilePage({
const followState: FollowState =
relRes.data?.status === "accepted" ? "following" : relRes.data?.status === "pending" ? "pending" : "none";
const contentHidden = (profile.is_private && !isOwner && !isAcceptedFollower) || isBlocked;
// Mirrors get_public_profile: hidden from viewers who cannot see content,
// and from the owner's public preview of a private account.
// The fallback select has no resume columns, so narrow each one.
const resumeText = (value: unknown) => (typeof value === "string" ? value : null);
const resume =
!contentHidden && !(isOwner && previewPublic && profile.is_private) && "headline" in profile
? {
headline: resumeText(profile.headline),
github: "github_url" in profile ? resumeText(profile.github_url) : null,
linkedin: "linkedin_url" in profile ? resumeText(profile.linkedin_url) : null,
website: "website_url" in profile ? resumeText(profile.website_url) : null,
}
: null;
const portfolioUnavailable = !bundle.ok && Boolean(bundle.unavailable);
const projection = bundle.ok ? bundle.data.projection : null;
const usePublicSections = !isOwner || previewPublic;
Expand Down Expand Up @@ -689,7 +705,9 @@ export default async function ProfilePage({
<UserBadges isPro={profile.is_pro} isFounder={profile.is_founder} isCampusFounder={profile.is_campus_founder} isVerifiedStudent={profile.verified_student} isBot={profile.is_bot} />
</div>
<p className="mt-0.5 text-[15px] text-[var(--ink-muted)]">@{profile.username}</p>
{resume?.headline && <p className="mt-2 text-[15px] text-[var(--ink)]">{resume.headline}</p>}
{metaLine && <p className="mt-2 text-sm text-[var(--ink-muted)]">{metaLine}</p>}
{resume && <ResumeLinks github={resume.github} linkedin={resume.linkedin} website={resume.website} />}
<div className="profile-stat-enter mt-4 flex flex-wrap gap-x-6 gap-y-1">
<Stat value={Number(counts.posts)} label="posts" accent={!!theme} />
<Stat value={Number(counts.followers)} label="followers" accent={!!theme} href={`/profile/${profile.username}/followers`} />
Expand All @@ -699,6 +717,19 @@ export default async function ProfilePage({
</div>
</section>

{isOwner &&
!previewPublic &&
projection &&
!projection.is_private &&
!PORTFOLIO_SECTIONS.some((section) => publicSectionVisible(projection, section)) && (
<p className="mt-3 text-sm text-[var(--ink-muted)]">
Your shared link shows almost nothing.{" "}
<Link href="/profile/edit#publication" className="text-[var(--ink)] underline underline-offset-2">
Publish your portfolio
</Link>
</p>
)}

{portfolioUnavailable && isOwner && (
<>
<UnavailableNotice />
Expand Down
27 changes: 25 additions & 2 deletions app/(app)/profile/edit/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getPostHogServerClient } from "@/lib/posthog-server";
import { DEGREE_VALUES as DEGREE_VALUES_RAW, pickPrimaryEducation } from "@/lib/education-options";
import { resolveInstitutionDomain } from "@/lib/resolve-domain";
import { isPortfolioSchemaMissing } from "@/lib/portfolio/errors";
import { parseOpenTo, parseStudyMode } from "@/lib/portfolio/owner";
import { parseOpenTo, parseResumeFields, parseStudyMode, type ResumeFields } from "@/lib/portfolio/owner";

// DEGREE_VALUES infers as a narrow string-literal union array (mapped from an
// `as const` options list), which Array.includes can't check against a plain
Expand Down Expand Up @@ -50,6 +50,19 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi
if (!openTo.ok) return { error: openTo.unavailable ? openTo.message : openTo.error };
const studyMode = parseStudyMode(formData.get("study_mode"));
if (!studyMode.ok) return { error: studyMode.unavailable ? studyMode.message : studyMode.error };
// Only the edit form sends these. Onboarding step 1 also calls this action
// with display_name + bio only, and must not wipe a saved headline or links.
let resumeFields: ResumeFields | null = null;
if (formData.has("headline")) {
const parsed = parseResumeFields({
headline: formData.get("headline"),
github_url: formData.get("github_url"),
linkedin_url: formData.get("linkedin_url"),
website_url: formData.get("website_url"),
});
if (!parsed.ok) return { error: parsed.unavailable ? parsed.message : parsed.error };
resumeFields = parsed.data;
}

// Trust boundary: never take the client's word for Pro status. Non-Pro
// requests simply don't touch profile_theme (a lapsed Pro keeps their
Expand All @@ -60,7 +73,7 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi
updates.profile_theme = isProfileTheme(themeRaw) ? themeRaw : null;
}

const withOpenTo = { ...updates, open_to: openTo.data, study_mode: studyMode.data };
const withOpenTo = { ...updates, open_to: openTo.data, study_mode: studyMode.data, ...(resumeFields ?? {}) };
const first = await supabase.from("profiles").update(withOpenTo).eq("id", user.id);
if (first.error && isPortfolioSchemaMissing(first.error)) {
const retry = await supabase.from("profiles").update(updates).eq("id", user.id);
Expand All @@ -74,6 +87,16 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi
// 7-day cooldown + dedupe live in the trigger) — nothing to call here.

getPostHogServerClient()?.capture({ distinctId: user.id, event: "profile_updated" });
if (resumeFields) {
getPostHogServerClient()?.capture({
distinctId: user.id,
event: "resume_fields_updated",
properties: {
has_headline: resumeFields.headline !== null,
link_count: [resumeFields.github_url, resumeFields.linkedin_url, resumeFields.website_url].filter(Boolean).length,
},
});
}

const { data: prof } = await supabase
.from("profiles")
Expand Down
6 changes: 5 additions & 1 deletion app/(app)/profile/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default async function EditProfilePage() {
if (!user) redirect("/login");

const profileSelect =
"username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme";
"username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme, headline, github_url, linkedin_url, website_url";
const profileFallback =
"username, display_name, avatar_url, banner_url, year, major, bio, goals, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme";
const firstProfile = await supabase.from("profiles").select(profileSelect).eq("id", user.id).single();
Expand Down Expand Up @@ -65,6 +65,10 @@ export default async function EditProfilePage() {
school: schoolRow?.school ?? "",
open_to: "open_to" in profile && Array.isArray(profile.open_to) ? profile.open_to : [],
study_mode: "study_mode" in profile && typeof profile.study_mode === "string" ? profile.study_mode : null,
headline: "headline" in profile && typeof profile.headline === "string" ? profile.headline : null,
github_url: "github_url" in profile && typeof profile.github_url === "string" ? profile.github_url : null,
linkedin_url: "linkedin_url" in profile && typeof profile.linkedin_url === "string" ? profile.linkedin_url : null,
website_url: "website_url" in profile && typeof profile.website_url === "string" ? profile.website_url : null,
}}
/>
{/* Experience + Education auto-save through their own actions; the single
Expand Down
10 changes: 10 additions & 0 deletions app/(app)/profile/projects/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
deleteProject,
parseProjectWrite,
parsePublishFlags,
publishedSections,
reorderProjects,
saveOwnerSettings,
setProjectStatus,
updateProjectFields,
} from "@/lib/portfolio/owner";
import { getPostHogServerClient } from "@/lib/posthog-server";
import type { PortfolioClient } from "@/lib/portfolio/client";
import type { ProjectWriteInput } from "@/types/portfolio";

Expand Down Expand Up @@ -181,6 +183,14 @@ export async function savePublicationAction(
{ isPrivate: ctx.data.isPrivate, isPro: ctx.data.isPro }
);
if (!saved.ok) return actionError(saved);
const sections = publishedSections(parsed.data);
if (sections.length > 0) {
getPostHogServerClient()?.capture({
distinctId: ctx.data.userId,
event: "portfolio_published",
properties: { sections, is_private: ctx.data.isPrivate },
});
}
revalidateProfile(ctx.data.username);
return {};
}
18 changes: 17 additions & 1 deletion app/(app)/search/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ import {
searchHref,
nextSearchOffset,
} from "@/lib/search";
import { hasDiscoveryFilters, hasPeopleFilters, parseDiscoveryFilters, postsDiscoveryHref } from "@/lib/discovery";
import {
activeFacets,
hasDiscoveryFilters,
hasPeopleFilters,
parseDiscoveryFilters,
postsDiscoveryHref,
} from "@/lib/discovery";
import { getPostHogServerClient } from "@/lib/posthog-server";

const POSTS_PREVIEW = 3;

Expand Down Expand Up @@ -49,6 +56,15 @@ export default async function SearchPage({
data: { user },
} = await supabase.auth.getUser();
const viewerId = user?.id ?? null;
// First page only, so paging through results is not counted as a new filter use.
// ponytail: fires on every render of a filtered first page (refresh counts again); dedupe in PostHog if noisy.
if (user && peoplePage === 1 && projectPage === 1 && hasDiscoveryFilters(filters)) {
getPostHogServerClient()?.capture({
distinctId: user.id,
event: "search_filtered",
properties: { facets: activeFacets(filters), has_query: hasQuery },
});
}
const { data: viewer } = user
? await supabase.from("profiles").select("major").eq("id", user.id).maybeSingle()
: { data: null };
Expand Down
Loading
Loading