From 2b5960f8866aa51d35a1f77c1b718c25e1ae3e79 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:57:52 -0400 Subject: [PATCH 01/22] Freeze billing and bot columns in the profile guard. --- ...arden_profile_guard_suggestions_groups.sql | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql diff --git a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql new file mode 100644 index 00000000..5860af80 --- /dev/null +++ b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql @@ -0,0 +1,45 @@ +-- Plan 001: privacy + consent hardening. +-- 1. guard_profile_privileged: re-freeze last_subscription_event_at (lost when +-- 20260713170000/20260716170000 re-created the function from a body that +-- predated 20260711120100) and freeze is_bot (added 20260724000000, never +-- frozen). Body copied from 20260716170000_freeze_profile_theme.sql; only +-- the two freeze lines are new. +-- 2. get_suggested_profiles: same privacy rules as search_people. +-- 3. create_group_conversation / add_group_member: the added member must +-- follow the caller (their own consent), and a member who left cannot be +-- re-added by someone else. +-- 4. guard_post_context_label_only: freeze hidden_by_suspension (authors +-- could mark their own admin-hidden post for restore on unsuspend). + +-- ============================================================ +-- guard_profile_privileged (stays NON-definer: it reads current_user) +-- ============================================================ +create or replace function public.guard_profile_privileged() +returns trigger +language plpgsql +set search_path = '' +as $function$ +begin + if current_user in ('authenticated', 'anon') then + new.is_pro := old.is_pro; + new.is_founder := old.is_founder; + new.is_campus_founder := old.is_campus_founder; + new.stripe_customer_id := old.stripe_customer_id; + new.pro_until := old.pro_until; + new.pro_source := old.pro_source; + new.is_admin := old.is_admin; + new.is_suspended := old.is_suspended; + new.email_domain := old.email_domain; + new.verified_student := old.verified_student; + new.last_subscription_event_at := old.last_subscription_event_at; + new.is_bot := old.is_bot; + if not public.is_pro_now(old.is_pro, old.pro_until) then + new.accent_color := old.accent_color; + new.avatar_is_animated := old.avatar_is_animated; + new.banner_url := old.banner_url; + new.profile_theme := old.profile_theme; + end if; + end if; + return new; +end; +$function$; From 15e645c4095fc1b4053d85e56cd1422af0280c63 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:58:06 -0400 Subject: [PATCH 02/22] Track portfolio shares by method. --- components/portfolio/SharePortfolioButton.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/portfolio/SharePortfolioButton.tsx b/components/portfolio/SharePortfolioButton.tsx index 4fab1b03..fa346f3d 100644 --- a/components/portfolio/SharePortfolioButton.tsx +++ b/components/portfolio/SharePortfolioButton.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { Check, Share2 } from "lucide-react"; +import posthog from "posthog-js"; import { ICON_SPRING } from "@/lib/motion/spring"; import { profileShareUrl } from "@/lib/portfolio/share"; @@ -21,6 +22,7 @@ export default function SharePortfolioButton({ if (typeof navigator !== "undefined" && typeof navigator.share === "function") { try { await navigator.share({ title: `${displayName} on samehere`, url, text: `@${username}` }); + posthog.capture("portfolio_shared", { method: "native" }); return; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; @@ -28,6 +30,7 @@ export default function SharePortfolioButton({ } try { await navigator.clipboard.writeText(url); + posthog.capture("portfolio_shared", { method: "copy" }); setCopied(true); window.setTimeout(() => setCopied(false), 1600); } catch { From 3340ac9a2fe8b3e3a7c962269ac017cb91caafe1 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:58:10 -0400 Subject: [PATCH 03/22] Apply search privacy rules to suggested profiles and clamp the limit. --- ...arden_profile_guard_suggestions_groups.sql | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql index 5860af80..343c7afb 100644 --- a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql +++ b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql @@ -43,3 +43,74 @@ begin return new; end; $function$; + +-- ============================================================ +-- get_suggested_profiles: mask private fields, skip suspended users, +-- respect hide_school in the filter and ranking, clamp the limit. +-- Body copied from 20260918011000_bet1_discovery_rpcs.sql. Signature unchanged. +-- ============================================================ +create or replace function public.get_suggested_profiles(p_school text default null, p_limit int default 3) +returns table( + id uuid, + username text, + display_name text, + avatar_url text, + year text, + major text, + goals text, + bio text, + is_pro boolean, + is_founder boolean, + is_campus_founder boolean, + verified_student boolean, + school text +) +language sql +security definer +set search_path = '' +stable +as $$ + with viewer as ( + select + p.study_mode, + coalesce(p.open_to, '{}'::text[]) as open_to, + case when p.hide_school then null else ps.school end as school + from public.profiles p + left join public.profile_school ps on ps.profile_id = p.id + where p.id = auth.uid() + ) + select + p.id, p.username, p.display_name, p.avatar_url, + case when p.is_private then null else p.year end, + case when p.is_private then null else p.major end, + case when p.is_private then null else p.goals end, + case when p.is_private then null else p.bio end, + p.is_pro, p.is_founder, p.is_campus_founder, p.verified_student, + case when p.hide_school then null else ps.school end + from public.profiles p + left join public.profile_school ps on ps.profile_id = p.id + left join viewer v on true + where auth.uid() is not null + and p.id <> auth.uid() + and p.is_suspended = false + and p.id not in (select public.get_blocked_ids()) + and not exists ( + select 1 from public.follows f + where f.follower_id = auth.uid() and f.following_id = p.id + ) + and (p_school is null or (not p.hide_school and ps.school = p_school)) + order by + (not p.is_private and p.study_mode is not null and v.study_mode is not null and p.study_mode = v.study_mode) desc, + case when p.is_private then 0 else ( + select count(*)::int + from unnest(coalesce(p.open_to, '{}'::text[])) as t(tag) + where t.tag = any(v.open_to) + ) end desc, + (not p.hide_school and ps.school is not null and v.school is not null and ps.school = v.school) desc, + p.verified_student desc, + p.created_at desc + limit least(20, greatest(1, coalesce(p_limit, 3))); +$$; + +revoke all on function public.get_suggested_profiles(text, int) from public, anon, authenticated; +grant execute on function public.get_suggested_profiles(text, int) to authenticated; From 091c507025a575d96f4e958a5235c4b45d3774fb Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:58:29 -0400 Subject: [PATCH 04/22] Track which portfolio sections a student publishes. --- app/(app)/profile/projects/actions.ts | 10 ++++++++++ lib/portfolio/owner.ts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/app/(app)/profile/projects/actions.ts b/app/(app)/profile/projects/actions.ts index 8686d5d1..b1fa3b56 100644 --- a/app/(app)/profile/projects/actions.ts +++ b/app/(app)/profile/projects/actions.ts @@ -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"; @@ -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 {}; } diff --git a/lib/portfolio/owner.ts b/lib/portfolio/owner.ts index ba6b0748..484bd2ec 100644 --- a/lib/portfolio/owner.ts +++ b/lib/portfolio/owner.ts @@ -368,6 +368,11 @@ export function parsePublishFlags(body: unknown): PortfolioResult flags[`publish_${section}`]); +} + export function parseOpenTo(values: unknown): PortfolioResult { const tags = Array.isArray(values) ? values.map(String) : []; const error = openToError(tags); From dba704dc931994e12cb350824ca9bb5ce2771dd5 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:58:46 -0400 Subject: [PATCH 05/22] Track new comments with the label of the post they answer. --- app/(app)/post/[id]/actions.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/(app)/post/[id]/actions.ts b/app/(app)/post/[id]/actions.ts index 986c52e8..f07b4d81 100644 --- a/app/(app)/post/[id]/actions.ts +++ b/app/(app)/post/[id]/actions.ts @@ -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 }; @@ -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 }; } From 7fad9ad39250e3d5b23cee361ba7fd06bec09242 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:58:56 -0400 Subject: [PATCH 06/22] Require group members to follow the adder and block re-adding people who left. --- ...arden_profile_guard_suggestions_groups.sql | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql index 343c7afb..dfcfe7fe 100644 --- a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql +++ b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql @@ -114,3 +114,167 @@ $$; revoke all on function public.get_suggested_profiles(text, int) from public, anon, authenticated; grant execute on function public.get_suggested_profiles(text, int) to authenticated; + +-- Group functions below: bodies copied from 20260716230000_group_add_follow_edge.sql. +-- Changes: the added member must follow the caller, and add_group_member +-- refuses to re-add someone who left. +-- ============================================================ +-- create_group_conversation +-- ============================================================ +create or replace function public.create_group_conversation(p_title text, p_member_ids uuid[]) +returns uuid +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_me uuid := auth.uid(); + v_title text := trim(coalesce(p_title, '')); + v_members uuid[]; + v_member uuid; + v_total int; + v_conv uuid; +begin + if v_me is null then + raise exception 'not authenticated'; + end if; + if char_length(v_title) < 1 or char_length(v_title) > 60 then + raise exception 'group title must be 1-60 characters'; + end if; + if p_member_ids is null or array_length(p_member_ids, 1) is null then + raise exception 'at least one other member required'; + end if; + + select array_agg(distinct m) into v_members + from unnest(p_member_ids) as m + where m <> v_me; + + v_total := coalesce(array_length(v_members, 1), 0) + 1; + if v_total < 2 or v_total > 10 then + raise exception 'group must have between 2 and 10 members'; + end if; + + foreach v_member in array v_members loop + if not exists (select 1 from public.profiles where id = v_member) then + raise exception 'no such user'; + end if; + if exists ( + select 1 from public.blocks + where (blocker_id = v_me and blocked_id = v_member) + or (blocker_id = v_member and blocked_id = v_me) + ) then + raise exception 'cannot add a blocked user to a group'; + end if; + if not exists ( + select 1 from public.follows f + where f.status = 'accepted' + and f.follower_id = v_member + and f.following_id = v_me + ) then + raise exception 'can only add people who follow you'; + end if; + end loop; + + insert into public.conversations (kind, title, created_by) + values ('group', v_title, v_me) + returning id into v_conv; + + insert into public.conversation_members (conversation_id, user_id) values (v_conv, v_me); + foreach v_member in array v_members loop + insert into public.conversation_members (conversation_id, user_id) values (v_conv, v_member); + end loop; + + return v_conv; +end; +$$; + +revoke all on function public.create_group_conversation(text, uuid[]) from public; +grant execute on function public.create_group_conversation(text, uuid[]) to authenticated; +-- `revoke all from public` does not strip anon by itself in this project +-- (default-privileges trap -- anon/authenticated are granted at the schema +-- level, not via PUBLIC); name anon explicitly. +revoke execute on function public.create_group_conversation(text, uuid[]) from anon; + +-- ============================================================ +-- add_group_member +-- ============================================================ +create or replace function public.add_group_member(p_conversation_id uuid, p_member_id uuid) +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_me uuid := auth.uid(); + v_kind text; + v_active_count int; +begin + if v_me is null then + raise exception 'not authenticated'; + end if; + if p_member_id is null then + raise exception 'member required'; + end if; + if not public.is_conversation_member(p_conversation_id) then + raise exception 'not a member of this conversation'; + end if; + + select kind into v_kind from public.conversations where id = p_conversation_id; + if v_kind is distinct from 'group' then + raise exception 'not a group conversation'; + end if; + + if not exists (select 1 from public.profiles where id = p_member_id) then + raise exception 'no such user'; + end if; + + if exists ( + select 1 from public.blocks + where (blocker_id = v_me and blocked_id = p_member_id) + or (blocker_id = p_member_id and blocked_id = v_me) + ) then + raise exception 'cannot add a blocked user to a group'; + end if; + + if not exists ( + select 1 from public.follows f + where f.status = 'accepted' + and f.follower_id = p_member_id + and f.following_id = v_me + ) then + raise exception 'can only add people who follow you'; + end if; + + if exists ( + select 1 from public.conversation_members + where conversation_id = p_conversation_id and user_id = p_member_id and left_at is null + ) then + raise exception 'already a member'; + end if; + + select count(*) into v_active_count + from public.conversation_members + where conversation_id = p_conversation_id and left_at is null; + + if v_active_count >= 10 then + raise exception 'group is full'; + end if; + + if exists ( + select 1 from public.conversation_members + where conversation_id = p_conversation_id and user_id = p_member_id and left_at is not null + ) then + raise exception 'they left this group'; + end if; + + insert into public.conversation_members (conversation_id, user_id, left_at) + values (p_conversation_id, p_member_id, null); +end; +$$; + +revoke all on function public.add_group_member(uuid, uuid) from public; +grant execute on function public.add_group_member(uuid, uuid) to authenticated; +-- `revoke all from public` does not strip anon by itself in this project +-- (default-privileges trap -- anon/authenticated are granted at the schema +-- level, not via PUBLIC); name anon explicitly. +revoke execute on function public.add_group_member(uuid, uuid) from anon; From f1fdad8c61657fb1851197d03a4c87dbae908a81 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:59:11 -0400 Subject: [PATCH 07/22] Track which search facets people filter by. --- app/(app)/search/page.tsx | 18 +++++++++++++++++- lib/discovery.ts | 5 +++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/(app)/search/page.tsx b/app/(app)/search/page.tsx index 207786e4..145c1df6 100644 --- a/app/(app)/search/page.tsx +++ b/app/(app)/search/page.tsx @@ -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; @@ -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 }; diff --git a/lib/discovery.ts b/lib/discovery.ts index 0fa2b566..df61b0c8 100644 --- a/lib/discovery.ts +++ b/lib/discovery.ts @@ -57,6 +57,11 @@ export function hasPeopleFilters(filters: DiscoveryFilters): boolean { return Boolean(filters.tag || filters.year || filters.major || filters.mode); } +/** Names of the facets that are set. Never values: major is free text. */ +export function activeFacets(filters: DiscoveryFilters): (keyof DiscoveryFilters)[] { + return (["tag", "year", "major", "mode", "label"] as const).filter((key) => filters[key] !== null); +} + export type DiscoveryHrefOpts = { q?: string; filters?: Partial; From 9e7a00a2179d8fce3932308753c3b27e9c896272 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:59:26 -0400 Subject: [PATCH 08/22] Track new group chats and their size. --- app/(app)/messages/actions.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/(app)/messages/actions.ts b/app/(app)/messages/actions.ts index 07ec3013..c0206150 100644 --- a/app/(app)/messages/actions.ts +++ b/app/(app)/messages/actions.ts @@ -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(); @@ -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 }; } From de1c1d5f58a872870cccd3fa3278470e61ac5485 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:59:47 -0400 Subject: [PATCH 09/22] Freeze hidden_by_suspension in the post update guard. --- ...arden_profile_guard_suggestions_groups.sql | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql index dfcfe7fe..d6761ac2 100644 --- a/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql +++ b/supabase/migrations/20260930100000_harden_profile_guard_suggestions_groups.sql @@ -278,3 +278,30 @@ grant execute on function public.add_group_member(uuid, uuid) to authenticated; -- (default-privileges trap -- anon/authenticated are granted at the schema -- level, not via PUBLIC); name anon explicitly. revoke execute on function public.add_group_member(uuid, uuid) from anon; + +-- ============================================================ +-- guard_post_context_label_only: body copied from +-- 20260910100000_portfolio_data_contracts.sql; only the hidden_by_suspension +-- freeze line is new. create or replace keeps the trigger binding. +-- ============================================================ +create or replace function public.guard_post_context_label_only() +returns trigger +language plpgsql +set search_path = '' +as $function$ +begin + if current_user in ('authenticated', 'anon') then + new.id := old.id; + new.user_id := old.user_id; + new.content := old.content; + new.created_at := old.created_at; + new.hidden := old.hidden; + new.media := old.media; + new.post_type := old.post_type; + new.hidden_by_suspension := old.hidden_by_suspension; + end if; + return new; +end; +$function$; + +revoke all on function public.guard_post_context_label_only() from public; From 57712693c5b323bd92e1af694f0158598703304b Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 00:59:58 -0400 Subject: [PATCH 10/22] Track finished onboarding with the steps a student completed. --- app/(app)/onboarding/actions.ts | 9 ++++++++- components/onboarding/OnboardingWizard.tsx | 22 +++++++++++++++++----- lib/onboarding.ts | 9 +++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 lib/onboarding.ts diff --git a/app/(app)/onboarding/actions.ts b/app/(app)/onboarding/actions.ts index 57bd5563..2425c5f8 100644 --- a/app/(app)/onboarding/actions.ts +++ b/app/(app)/onboarding/actions.ts @@ -3,6 +3,8 @@ 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"; function isRedirectSignal(err: unknown): boolean { return ( @@ -23,7 +25,7 @@ export async function saveOnboardingBasics(prev: EditState, formData: FormData): } } -export async function finishOnboarding(): Promise { +export async function finishOnboarding(stepsDoneRaw: unknown = []): Promise { const supabase = await createClient(); const { data: { user }, @@ -31,5 +33,10 @@ export async function finishOnboarding(): Promise { 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"); } diff --git a/components/onboarding/OnboardingWizard.tsx b/components/onboarding/OnboardingWizard.tsx index afff6a09..389fc1cf 100644 --- a/components/onboarding/OnboardingWizard.tsx +++ b/components/onboarding/OnboardingWizard.tsx @@ -17,6 +17,7 @@ import SchoolAutocomplete from "@/components/profile/SchoolAutocomplete"; import DateRangePicker from "@/components/profile/DateRangePicker"; import Select from "@/components/ui/Select"; import { DEGREE_OPTIONS } from "@/lib/education-options"; +import type { OnboardingStep } from "@/lib/onboarding"; export type OnboardingProfile = { username: string; @@ -41,6 +42,8 @@ const field = "input-base mt-1.5"; export default function OnboardingWizard({ profile }: { profile: OnboardingProfile }) { const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [stepsDone, setStepsDone] = useState([]); + const markDone = (s: OnboardingStep) => setStepsDone((prev) => (prev.includes(s) ? prev : [...prev, s])); const reduce = useReducedMotion(); const currentYear = new Date().getFullYear(); @@ -66,7 +69,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startBasics(async () => { const result = await saveOnboardingBasics({}, fd); if (result.error) setBasicsError(result.error); - else setStep(2); + else { + markDone("basics"); + setStep(2); + } }); } @@ -77,7 +83,7 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi function onFinish() { startFinish(async () => { - await finishOnboarding(); + await finishOnboarding(stepsDone); }); } @@ -91,7 +97,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startPost(async () => { const result: ComposerState = await createPost({}, fd); if (result.error) setPostError(result.error); - else setStep(3); + else { + markDone("post"); + setStep(3); + } }); } @@ -105,7 +114,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startEdu(async () => { const result: EducationState = await addEducation({}, fd); if (result.error) setEduError(result.error); - else setStep(4); + else { + markDone("education"); + setStep(4); + } }); } @@ -119,7 +131,7 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startExp(async () => { const result: ExperienceState = await addExperience({}, fd); if (result.error) setExpError(result.error); - else await finishOnboarding(); + else await finishOnboarding([...stepsDone, "experience"]); }); } diff --git a/lib/onboarding.ts b/lib/onboarding.ts new file mode 100644 index 00000000..0674d017 --- /dev/null +++ b/lib/onboarding.ts @@ -0,0 +1,9 @@ +export const ONBOARDING_STEPS = ["basics", "post", "education", "experience"] as const; +export type OnboardingStep = (typeof ONBOARDING_STEPS)[number]; + +/** Server-action input is untrusted: keep known step names, dedupe, canonical order. */ +export function parseStepsDone(raw: unknown): OnboardingStep[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(raw.filter((v): v is string => typeof v === "string")); + return ONBOARDING_STEPS.filter((step) => seen.has(step)); +} From c3e0e9ec3d8aff32b34e77e10f41ef8ea74eb29b Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:00:37 -0400 Subject: [PATCH 11/22] Offer only followers as group members to match the database rule. --- app/(app)/messages/actions.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/(app)/messages/actions.ts b/app/(app)/messages/actions.ts index 07ec3013..15553bfb 100644 --- a/app/(app)/messages/actions.ts +++ b/app/(app)/messages/actions.ts @@ -37,10 +37,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 { const supabase = await createClient(); const { @@ -52,14 +52,14 @@ export async function listFollowedForGroup(): Promise { 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)); } From 44e6102fcbd134202b95c1e5124134b7892d7cfa Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:01:00 -0400 Subject: [PATCH 12/22] Test the analytics helpers for sections, facets, and onboarding steps. --- lib/discovery.test.ts | 17 +++++++++++++++++ lib/onboarding.test.ts | 14 ++++++++++++++ lib/portfolio/owner.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 lib/onboarding.test.ts diff --git a/lib/discovery.test.ts b/lib/discovery.test.ts index a7350ee4..d2ebb768 100644 --- a/lib/discovery.test.ts +++ b/lib/discovery.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + activeFacets, discoveryHref, hasDiscoveryFilters, hasPeopleFilters, @@ -73,3 +74,19 @@ describe("discoveryHref / empty-query browse + chips", () => { expect(discoveryHref({ q: "rust", filters: chips })).toBe("/search?q=rust&mode=online&label=stuck"); }); }); + +describe("activeFacets", () => { + it("is empty with no filters", () => { + expect(activeFacets(parseDiscoveryFilters({}))).toEqual([]); + }); + + it("names the set facets in order", () => { + expect(activeFacets(parseDiscoveryFilters({ tag: "study", label: "stuck" }))).toEqual(["tag", "label"]); + }); + + it("sends the major facet name, never its value", () => { + const facets = activeFacets(parseDiscoveryFilters({ major: "Computer Science" })); + expect(facets).toEqual(["major"]); + expect(facets).not.toContain("Computer Science"); + }); +}); diff --git a/lib/onboarding.test.ts b/lib/onboarding.test.ts new file mode 100644 index 00000000..7d36d5f9 --- /dev/null +++ b/lib/onboarding.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { parseStepsDone } from "./onboarding"; + +describe("parseStepsDone", () => { + it("returns nothing for non-array input", () => { + expect(parseStepsDone(undefined)).toEqual([]); + expect(parseStepsDone("basics")).toEqual([]); + expect(parseStepsDone({})).toEqual([]); + }); + + it("keeps known steps, dedupes, and sorts canonically", () => { + expect(parseStepsDone(["experience", "basics", "junk", 3, "basics"])).toEqual(["basics", "experience"]); + }); +}); diff --git a/lib/portfolio/owner.test.ts b/lib/portfolio/owner.test.ts index 4015bda5..165586e8 100644 --- a/lib/portfolio/owner.test.ts +++ b/lib/portfolio/owner.test.ts @@ -3,6 +3,7 @@ import { PORTFOLIO_UNAVAILABLE, type PortfolioQueryError } from "./errors"; import { createProject, parseProjectWrite, + publishedSections, reorderProjects, saveOwnerSettings, setProjectStatus, @@ -394,3 +395,27 @@ describe("missing migration", () => { expect(result).toEqual({ ok: false, unavailable: true, message: PORTFOLIO_UNAVAILABLE }); }); }); + +describe("publishedSections", () => { + const off = { + publish_intro: false, + publish_projects: false, + publish_activity: false, + publish_experience: false, + publish_education: false, + publish_posts: false, + allow_indexing: false, + }; + + it("returns nothing when every flag is off", () => { + expect(publishedSections(off)).toEqual([]); + }); + + it("returns the on sections in canonical order", () => { + expect(publishedSections({ ...off, publish_posts: true, publish_intro: true })).toEqual(["intro", "posts"]); + }); + + it("does not count allow_indexing as a section", () => { + expect(publishedSections({ ...off, allow_indexing: true })).toEqual([]); + }); +}); From 74a71e5d621a6e302d26753feee0bc597032ea53 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:04:44 -0400 Subject: [PATCH 13/22] Add rls_test assertions for the profile guard, suggestions, group consent, and post guard hardening. --- supabase/tests/rls_test.sql | 241 +++++++++++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 1 deletion(-) diff --git a/supabase/tests/rls_test.sql b/supabase/tests/rls_test.sql index 1ecea24f..c295be6e 100644 --- a/supabase/tests/rls_test.sql +++ b/supabase/tests/rls_test.sql @@ -2529,6 +2529,245 @@ exception when others then end $$; reset role; +-- ============ P001: 20260930100000_harden_profile_guard_suggestions_groups.sql ============ +-- Setup, as postgres. Earlier blocks leave B suspended (M5) and A blocking B +-- (H5_reverse); clear those plus every follow/block among A, B, C so A and C +-- are eligible suggestions for B and the group checks start from no edges. +-- Seed known values for B's frozen columns, and give private A non-null +-- profile fields so the masking assertion is meaningful. +set local role postgres; +do $$ +declare + v_a uuid := (select id from tests_fixture where key = 'a'); + v_b uuid := (select id from tests_fixture where key = 'b'); + v_c uuid := (select id from tests_fixture where key = 'c'); +begin + update public.profiles set is_suspended = false where id = v_b; + delete from public.blocks + where blocker_id in (v_a, v_b, v_c) and blocked_id in (v_a, v_b, v_c); + delete from public.follows + where follower_id in (v_a, v_b, v_c) and following_id in (v_a, v_b, v_c); + update public.profiles + set last_subscription_event_at = '2026-01-01T00:00:00Z', is_bot = false + where id = v_b; + update public.profiles + set bio = 'private bio', goals = 'private goals', year = 'junior', major = 'private major' + where id = v_a; +end $$; +reset role; + +-- ============ P001_guard_sub_event_frozen / P001_guard_is_bot_frozen ============ +-- B writes its own billing high-water mark and bot flag through the owner +-- UPDATE policy; guard_profile_privileged must silently keep the old values. +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +begin + update public.profiles set last_subscription_event_at = '2100-01-01T00:00:00Z' + where id = (select id from tests_fixture where key = 'b'); + update public.profiles set is_bot = true + where id = (select id from tests_fixture where key = 'b'); +end $$; +reset role; + +set local role postgres; +do $$ +declare v_ts timestamptz; +begin + select last_subscription_event_at into v_ts from public.profiles + where id = (select id from tests_fixture where key = 'b'); + if v_ts is distinct from '2026-01-01T00:00:00Z'::timestamptz then + raise exception 'P001_guard_sub_event_frozen REGRESSION: B set its own last_subscription_event_at to %; guard_profile_privileged must freeze it', v_ts; + end if; + insert into tests_results values ('P001_guard_sub_event_frozen', true, 'ok'); +exception when others then + insert into tests_results values ('P001_guard_sub_event_frozen', false, sqlerrm); +end $$; + +do $$ +declare v_bot boolean; +begin + select is_bot into v_bot from public.profiles + where id = (select id from tests_fixture where key = 'b'); + if v_bot then + raise exception 'P001_guard_is_bot_frozen REGRESSION: B set its own is_bot = true; guard_profile_privileged must freeze it'; + end if; + insert into tests_results values ('P001_guard_is_bot_frozen', true, 'ok'); +exception when others then + insert into tests_results values ('P001_guard_is_bot_frozen', false, sqlerrm); +end $$; +reset role; + +-- ============ P001_suggested_private_masked / P001_suggested_limit_clamped ============ +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +declare r record; +begin + select * into r from public.get_suggested_profiles(null, 50) s + where s.id = (select id from tests_fixture where key = 'a'); + if not found then + raise exception 'P001_suggested_private_masked SETUP: private fixture A missing from B''s suggestions, so the masking check would be vacuous'; + end if; + if r.bio is not null or r.goals is not null or r.year is not null or r.major is not null then + raise exception 'P001_suggested_private_masked REGRESSION: get_suggested_profiles returned private A''s bio/goals/year/major'; + end if; + insert into tests_results values ('P001_suggested_private_masked', true, 'ok'); +exception when others then + insert into tests_results values ('P001_suggested_private_masked', false, sqlerrm); +end $$; + +-- ponytail: the fixture set has fewer than 21 eligible profiles, so this only +-- bites once the DB is larger; seed 21+ users here if it ever needs to. +do $$ +declare v_n int; +begin + select count(*) into v_n from public.get_suggested_profiles(null, 100000); + if v_n > 20 then + raise exception 'P001_suggested_limit_clamped REGRESSION: get_suggested_profiles(null, 100000) returned % rows; limit must clamp to 20', v_n; + end if; + insert into tests_results values ('P001_suggested_limit_clamped', true, 'ok'); +exception when others then + insert into tests_results values ('P001_suggested_limit_clamped', false, sqlerrm); +end $$; +reset role; + +-- ============ P001_suggested_suspended_hidden ============ +set local role postgres; +update public.profiles set is_suspended = true where id = (select id from tests_fixture where key = 'c'); +reset role; + +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +begin + if exists ( + select 1 from public.get_suggested_profiles(null, 20) s + where s.id = (select id from tests_fixture where key = 'c') + ) then + raise exception 'P001_suggested_suspended_hidden REGRESSION: get_suggested_profiles returned suspended user C'; + end if; + insert into tests_results values ('P001_suggested_suspended_hidden', true, 'ok'); +exception when others then + insert into tests_results values ('P001_suggested_suspended_hidden', false, sqlerrm); +end $$; +reset role; + +set local role postgres; +update public.profiles set is_suspended = false where id = (select id from tests_fixture where key = 'c'); +reset role; + +-- ============ P001_group_add_requires_member_follow ============ +-- B follows public C (auto-accepted, no consent from C); C does not follow B. +set local role postgres; +insert into public.follows (follower_id, following_id, status) +values ( + (select id from tests_fixture where key = 'b'), + (select id from tests_fixture where key = 'c'), + 'accepted' +); +reset role; + +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +declare v_raised boolean := false; +begin + begin + perform public.create_group_conversation('t', array[(select id from tests_fixture where key = 'c')]); + exception when others then + if sqlerrm not like '%can only add people who follow you%' then + raise exception 'P001_group_add_requires_member_follow WRONG REASON: %', sqlerrm; + end if; + v_raised := true; + end; + if not v_raised then + raise exception 'P001_group_add_requires_member_follow REGRESSION: B pulled C into a group though C does not follow B'; + end if; + insert into tests_results values ('P001_group_add_requires_member_follow', true, 'ok'); +exception when others then + insert into tests_results values ('P001_group_add_requires_member_follow', false, sqlerrm); +end $$; +reset role; + +-- ============ P001_group_readd_after_leave_denied ============ +-- C follows B, so B may start a group with C; C then leaves, and B must not +-- be able to pull C back in. +set local role postgres; +insert into public.follows (follower_id, following_id, status) +values ( + (select id from tests_fixture where key = 'c'), + (select id from tests_fixture where key = 'b'), + 'accepted' +); +reset role; + +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +begin + insert into tests_fixture (key, id) + values ('p001_group', public.create_group_conversation('p001', array[(select id from tests_fixture where key = 'c')])); +end $$; +reset role; + +set local role postgres; +update public.conversation_members set left_at = now() + where conversation_id = (select id from tests_fixture where key = 'p001_group') + and user_id = (select id from tests_fixture where key = 'c'); +reset role; + +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +declare v_raised boolean := false; +begin + begin + perform public.add_group_member( + (select id from tests_fixture where key = 'p001_group'), + (select id from tests_fixture where key = 'c') + ); + exception when others then + if sqlerrm not like '%they left this group%' then + raise exception 'P001_group_readd_after_leave_denied WRONG REASON: %', sqlerrm; + end if; + v_raised := true; + end; + if not v_raised then + raise exception 'P001_group_readd_after_leave_denied REGRESSION: B re-added C after C left the group'; + end if; + insert into tests_results values ('P001_group_readd_after_leave_denied', true, 'ok'); +exception when others then + insert into tests_results values ('P001_group_readd_after_leave_denied', false, sqlerrm); +end $$; +reset role; + +-- ============ P001_post_hidden_by_suspension_frozen ============ +-- An author must not be able to mark their own admin-hidden post as +-- suspension collateral (which admin_unsuspend_user would then restore). +set local role postgres; +update public.posts set hidden = true, hidden_by_suspension = false + where id = (select id from tests_fixture where key = 'post_b'); +reset role; + +select tests.as_user(id) from tests_fixture where key = 'b'; +do $$ +begin + update public.posts set hidden_by_suspension = true + where id = (select id from tests_fixture where key = 'post_b'); +end $$; +reset role; + +set local role postgres; +do $$ +declare v_flag boolean; +begin + select hidden_by_suspension into v_flag from public.posts + where id = (select id from tests_fixture where key = 'post_b'); + if v_flag then + raise exception 'P001_post_hidden_by_suspension_frozen REGRESSION: author B set hidden_by_suspension on its own admin-hidden post'; + end if; + insert into tests_results values ('P001_post_hidden_by_suspension_frozen', true, 'ok'); +exception when others then + insert into tests_results values ('P001_post_hidden_by_suspension_frozen', false, sqlerrm); +end $$; +update public.posts set hidden = false where id = (select id from tests_fixture where key = 'post_b'); +reset role; + -- ============ report ============ -- Print the PASS/FAIL table FIRST so the operator sees exactly which assertions -- failed, then raise so psql exits non-zero and the harness actually gates. @@ -2546,7 +2785,7 @@ declare v_failed int; begin select count(*) into v_failed from tests_results where not passed; if v_failed > 0 then - raise exception '% assertion(s) failed — see table above. Every assertion in this file is expected to PASS: C1, C1_helper, H1, H1_positive, H2, C2, C2_forgery, M3_comments, M3_reactions, H5, H5_reverse, H5b, M8_multi_target, M8_snapshot, M8_no_column_privilege, M8_block_then_report, M8_evidence_survives, M4, M5_profile_view_denied, M5_profile_view, M5_write, anon_sees_no_posts, non_follower_sees_no_private_posts, public_surface, get_public_profile_privacy, storage_post_media_policy_count, CLUBS_1, CLUBS_2_non_member, CLUBS_2_member, CLUBS_3, CLUBS_4, CLUBS_4_unchanged, CLUBS_5, CLUBS_6, CLUBS_7a, CLUBS_7b, CLUBS_8, CLUBS_V2_1, CLUBS_V2_2, CLUBS_V2_3, CLUBS_V2_7a, CLUBS_V2_4, CLUBS_V2_7b, CLUBS_V2_5_officer_denied, CLUBS_V2_5_owner_allowed, CLUBS_V2_6_outsider, CLUBS_V2_6_pending, CLUBS_V2_8, CLUBS_V2_9, H1_suggested_profiles, CLUBS_V2_12_outsider, CLUBS_V2_12_anon, SIGNUP_RL_anon_execute, SIGNUP_RL_no_table_access_anon, SIGNUP_RL_no_table_access_authenticated, EXPERIENCES_owner_insert, EXPERIENCES_owner_select, EXPERIENCES_owner_update, EXPERIENCES_b_select_a, EXPERIENCES_b_update_denied, EXPERIENCES_b_delete_denied, EXPERIENCES_anon_select_denied, EXPERIENCES_owner_delete, EXPERIENCES_cap, JOB_LISTINGS_authenticated_select, JOB_LISTINGS_authenticated_insert_denied, JOB_LISTINGS_anon_select_denied, JOB_FIT_owner_insert_select, JOB_FIT_b_select_a_denied, JOB_PITCHES_owner_insert_select, JOB_PITCHES_b_select_a_denied, JOB_SAVES_owner_insert_select, JOB_SAVES_b_select_a_denied, JOB_SAVES_b_delete_a_denied, JOB_SAVES_owner_delete, JOB_SAVES_anon_select_denied, REFERRAL_JOINED_owner_select, REFERRAL_JOINED_b_select_denied.', v_failed; + raise exception '% assertion(s) failed — see table above. Every assertion in this file is expected to PASS: C1, C1_helper, H1, H1_positive, H2, C2, C2_forgery, M3_comments, M3_reactions, H5, H5_reverse, H5b, M8_multi_target, M8_snapshot, M8_no_column_privilege, M8_block_then_report, M8_evidence_survives, M4, M5_profile_view_denied, M5_profile_view, M5_write, anon_sees_no_posts, non_follower_sees_no_private_posts, public_surface, get_public_profile_privacy, storage_post_media_policy_count, CLUBS_1, CLUBS_2_non_member, CLUBS_2_member, CLUBS_3, CLUBS_4, CLUBS_4_unchanged, CLUBS_5, CLUBS_6, CLUBS_7a, CLUBS_7b, CLUBS_8, CLUBS_V2_1, CLUBS_V2_2, CLUBS_V2_3, CLUBS_V2_7a, CLUBS_V2_4, CLUBS_V2_7b, CLUBS_V2_5_officer_denied, CLUBS_V2_5_owner_allowed, CLUBS_V2_6_outsider, CLUBS_V2_6_pending, CLUBS_V2_8, CLUBS_V2_9, H1_suggested_profiles, CLUBS_V2_12_outsider, CLUBS_V2_12_anon, SIGNUP_RL_anon_execute, SIGNUP_RL_no_table_access_anon, SIGNUP_RL_no_table_access_authenticated, EXPERIENCES_owner_insert, EXPERIENCES_owner_select, EXPERIENCES_owner_update, EXPERIENCES_b_select_a, EXPERIENCES_b_update_denied, EXPERIENCES_b_delete_denied, EXPERIENCES_anon_select_denied, EXPERIENCES_owner_delete, EXPERIENCES_cap, JOB_LISTINGS_authenticated_select, JOB_LISTINGS_authenticated_insert_denied, JOB_LISTINGS_anon_select_denied, JOB_FIT_owner_insert_select, JOB_FIT_b_select_a_denied, JOB_PITCHES_owner_insert_select, JOB_PITCHES_b_select_a_denied, JOB_SAVES_owner_insert_select, JOB_SAVES_b_select_a_denied, JOB_SAVES_b_delete_a_denied, JOB_SAVES_owner_delete, JOB_SAVES_anon_select_denied, REFERRAL_JOINED_owner_select, REFERRAL_JOINED_b_select_denied, P001_guard_sub_event_frozen, P001_guard_is_bot_frozen, P001_suggested_private_masked, P001_suggested_limit_clamped, P001_suggested_suspended_hidden, P001_group_add_requires_member_follow, P001_group_readd_after_leave_denied, P001_post_hidden_by_suspension_frozen.', v_failed; end if; end $$; From d7b286a54b67af8f8c68671ea9e2522a97c48e54 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:08:35 -0400 Subject: [PATCH 14/22] Add headline and profile links to profiles. --- .../20260930120000_resume_profile_fields.sql | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 supabase/migrations/20260930120000_resume_profile_fields.sql diff --git a/supabase/migrations/20260930120000_resume_profile_fields.sql b/supabase/migrations/20260930120000_resume_profile_fields.sql new file mode 100644 index 00000000..1cdaa6b7 --- /dev/null +++ b/supabase/migrations/20260930120000_resume_profile_fields.sql @@ -0,0 +1,82 @@ +-- Plan 005: resume-grade profile fields (headline + three profile links). +-- Skill chips are derived in app code from published projects; no column. +-- Column-grant trap: profiles SELECT is per column. An ungranted column +-- 42501s every client query that names it (rls_test profiles_column_grants). +-- Host rules (github.com, linkedin.com) are enforced in the server action; +-- the DB only enforces http(s) shape, like portfolio_projects urls. + +alter table public.profiles + add column if not exists headline text, + add column if not exists github_url text, + add column if not exists linkedin_url text, + add column if not exists website_url text; + +alter table public.profiles drop constraint if exists profiles_headline_len; +alter table public.profiles drop constraint if exists profiles_github_url_ok; +alter table public.profiles drop constraint if exists profiles_linkedin_url_ok; +alter table public.profiles drop constraint if exists profiles_website_url_ok; + +alter table public.profiles + add constraint profiles_headline_len + check (headline is null or char_length(headline) <= 120), + add constraint profiles_github_url_ok + check (public.portfolio_http_url_ok(github_url)), + add constraint profiles_linkedin_url_ok + check (public.portfolio_http_url_ok(linkedin_url)), + add constraint profiles_website_url_ok + check (public.portfolio_http_url_ok(website_url)); + +grant select (headline, github_url, linkedin_url, website_url) on public.profiles to authenticated, anon; +grant update (headline, github_url, linkedin_url, website_url) on public.profiles to authenticated; + +-- Recreate get_public_profile with the four fields appended at the end. +-- Same privacy as open_to: nulled when private or suspended. +drop function if exists public.get_public_profile(text); +create function public.get_public_profile(p_username text) +returns table( + id uuid, username text, display_name text, avatar_url text, banner_url text, + accent_color text, is_pro boolean, is_founder boolean, is_campus_founder boolean, + is_private boolean, heatmap_visibility text, + year text, major text, bio text, goals text, school text, + verified_student boolean, is_bot boolean, open_to text[], study_mode text, + headline text, github_url text, linkedin_url text, website_url text +) +language sql security definer set search_path = '' stable as $$ + select + p.id, + p.username, + p.display_name, + p.avatar_url, + case when p.is_suspended then null + when public.is_pro_now(p.is_pro, p.pro_until) then p.banner_url end, + case when p.is_suspended then null + when public.is_pro_now(p.is_pro, p.pro_until) then p.accent_color end, + public.is_pro_now(p.is_pro, p.pro_until) and not p.is_suspended, + p.is_founder, + p.is_campus_founder, + p.is_private, + p.heatmap_visibility, + case when p.is_private or p.is_suspended then null else p.year end, + case when p.is_private or p.is_suspended then null else p.major end, + case when p.is_private or p.is_suspended then null else p.bio end, + case when p.is_private or p.is_suspended then null else p.goals end, + case when p.is_private or p.is_suspended or p.hide_school then null else ps.school end, + p.verified_student, + p.is_bot, + case when p.is_private or p.is_suspended then null else p.open_to end, + case when p.is_private or p.is_suspended then null else p.study_mode end, + case when p.is_private or p.is_suspended then null else p.headline end, + case when p.is_private or p.is_suspended then null else p.github_url end, + case when p.is_private or p.is_suspended then null else p.linkedin_url end, + case when p.is_private or p.is_suspended then null else p.website_url end + from public.profiles p + left join public.profile_school ps on ps.profile_id = p.id + where lower(p.username) = lower(p_username) + and ( + auth.uid() is null + or p.id not in (select public.get_blocked_ids()) + ); +$$; + +revoke all on function public.get_public_profile(text) from public, anon, authenticated; +grant execute on function public.get_public_profile(text) to anon, authenticated; From bd684bf61e7ee106f16ec7308f1c9096bc94878d Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:09:12 -0400 Subject: [PATCH 15/22] Type the headline and profile link columns. --- types/database.types.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/types/database.types.ts b/types/database.types.ts index de9e24d6..4ea301ce 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -1495,6 +1495,10 @@ export type Database = { onboarded_at: string | null open_to: string[] study_mode: string | null + headline: string | null + github_url: string | null + linkedin_url: string | null + website_url: string | null pro_source: string | null pro_until: string | null profile_theme: string | null @@ -1531,6 +1535,10 @@ export type Database = { onboarded_at?: string | null open_to?: string[] study_mode?: string | null + headline?: string | null + github_url?: string | null + linkedin_url?: string | null + website_url?: string | null pro_source?: string | null pro_until?: string | null profile_theme?: string | null @@ -1567,6 +1575,10 @@ export type Database = { onboarded_at?: string | null open_to?: string[] study_mode?: string | null + headline?: string | null + github_url?: string | null + linkedin_url?: string | null + website_url?: string | null pro_source?: string | null pro_until?: string | null profile_theme?: string | null @@ -2304,6 +2316,10 @@ export type Database = { open_to: string[] | null school: string study_mode: string | null + headline: string | null + github_url: string | null + linkedin_url: string | null + website_url: string | null username: string verified_student: boolean year: string From 661942d82f0e0e3f4066cf3f73397805febf5a25 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:09:31 -0400 Subject: [PATCH 16/22] Test that headline and links follow public profile privacy. --- supabase/tests/rls_test.sql | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/supabase/tests/rls_test.sql b/supabase/tests/rls_test.sql index c295be6e..34fca38d 100644 --- a/supabase/tests/rls_test.sql +++ b/supabase/tests/rls_test.sql @@ -2768,6 +2768,42 @@ end $$; update public.posts set hidden = false where id = (select id from tests_fixture where key = 'post_b'); reset role; +-- ============ RESUME_FIELDS: headline + links follow get_public_profile privacy ============ +update public.profiles + set headline = 'Private headline', github_url = 'https://github.com/rls-a', + linkedin_url = 'https://www.linkedin.com/in/rls-a', website_url = 'https://a.example.com' + where id = (select id from tests_fixture where key = 'a'); +update public.profiles + set headline = 'Public headline', github_url = 'https://github.com/rls-c' + where id = (select id from tests_fixture where key = 'c'); + +select tests.as_anon(); +do $$ +declare r record; +begin + select * into r from public.get_public_profile('rls_test_a'); + if r.headline is not null or r.github_url is not null + or r.linkedin_url is not null or r.website_url is not null then + raise exception 'RESUME_FIELDS_private_nulled REGRESSION: private account headline/links leaked to anon (headline=%, github=%, linkedin=%, website=%)', r.headline, r.github_url, r.linkedin_url, r.website_url; + end if; + insert into tests_results values ('RESUME_FIELDS_private_nulled', true, 'ok'); +exception when others then + insert into tests_results values ('RESUME_FIELDS_private_nulled', false, sqlerrm); +end $$; +do $$ +declare r record; +begin + select * into r from public.get_public_profile('rls_test_c'); + if r.headline is distinct from 'Public headline' + or r.github_url is distinct from 'https://github.com/rls-c' then + raise exception 'RESUME_FIELDS_anon_public_read REGRESSION: anon could not read a public headline/link (headline=%, github=%)', r.headline, r.github_url; + end if; + insert into tests_results values ('RESUME_FIELDS_anon_public_read', true, 'ok'); +exception when others then + insert into tests_results values ('RESUME_FIELDS_anon_public_read', false, sqlerrm); +end $$; +reset role; + -- ============ report ============ -- Print the PASS/FAIL table FIRST so the operator sees exactly which assertions -- failed, then raise so psql exits non-zero and the harness actually gates. @@ -2785,7 +2821,7 @@ declare v_failed int; begin select count(*) into v_failed from tests_results where not passed; if v_failed > 0 then - raise exception '% assertion(s) failed — see table above. Every assertion in this file is expected to PASS: C1, C1_helper, H1, H1_positive, H2, C2, C2_forgery, M3_comments, M3_reactions, H5, H5_reverse, H5b, M8_multi_target, M8_snapshot, M8_no_column_privilege, M8_block_then_report, M8_evidence_survives, M4, M5_profile_view_denied, M5_profile_view, M5_write, anon_sees_no_posts, non_follower_sees_no_private_posts, public_surface, get_public_profile_privacy, storage_post_media_policy_count, CLUBS_1, CLUBS_2_non_member, CLUBS_2_member, CLUBS_3, CLUBS_4, CLUBS_4_unchanged, CLUBS_5, CLUBS_6, CLUBS_7a, CLUBS_7b, CLUBS_8, CLUBS_V2_1, CLUBS_V2_2, CLUBS_V2_3, CLUBS_V2_7a, CLUBS_V2_4, CLUBS_V2_7b, CLUBS_V2_5_officer_denied, CLUBS_V2_5_owner_allowed, CLUBS_V2_6_outsider, CLUBS_V2_6_pending, CLUBS_V2_8, CLUBS_V2_9, H1_suggested_profiles, CLUBS_V2_12_outsider, CLUBS_V2_12_anon, SIGNUP_RL_anon_execute, SIGNUP_RL_no_table_access_anon, SIGNUP_RL_no_table_access_authenticated, EXPERIENCES_owner_insert, EXPERIENCES_owner_select, EXPERIENCES_owner_update, EXPERIENCES_b_select_a, EXPERIENCES_b_update_denied, EXPERIENCES_b_delete_denied, EXPERIENCES_anon_select_denied, EXPERIENCES_owner_delete, EXPERIENCES_cap, JOB_LISTINGS_authenticated_select, JOB_LISTINGS_authenticated_insert_denied, JOB_LISTINGS_anon_select_denied, JOB_FIT_owner_insert_select, JOB_FIT_b_select_a_denied, JOB_PITCHES_owner_insert_select, JOB_PITCHES_b_select_a_denied, JOB_SAVES_owner_insert_select, JOB_SAVES_b_select_a_denied, JOB_SAVES_b_delete_a_denied, JOB_SAVES_owner_delete, JOB_SAVES_anon_select_denied, REFERRAL_JOINED_owner_select, REFERRAL_JOINED_b_select_denied, P001_guard_sub_event_frozen, P001_guard_is_bot_frozen, P001_suggested_private_masked, P001_suggested_limit_clamped, P001_suggested_suspended_hidden, P001_group_add_requires_member_follow, P001_group_readd_after_leave_denied, P001_post_hidden_by_suspension_frozen.', v_failed; + raise exception '% assertion(s) failed — see table above. Every assertion in this file is expected to PASS: C1, C1_helper, H1, H1_positive, H2, C2, C2_forgery, M3_comments, M3_reactions, H5, H5_reverse, H5b, M8_multi_target, M8_snapshot, M8_no_column_privilege, M8_block_then_report, M8_evidence_survives, M4, M5_profile_view_denied, M5_profile_view, M5_write, anon_sees_no_posts, non_follower_sees_no_private_posts, public_surface, get_public_profile_privacy, storage_post_media_policy_count, CLUBS_1, CLUBS_2_non_member, CLUBS_2_member, CLUBS_3, CLUBS_4, CLUBS_4_unchanged, CLUBS_5, CLUBS_6, CLUBS_7a, CLUBS_7b, CLUBS_8, CLUBS_V2_1, CLUBS_V2_2, CLUBS_V2_3, CLUBS_V2_7a, CLUBS_V2_4, CLUBS_V2_7b, CLUBS_V2_5_officer_denied, CLUBS_V2_5_owner_allowed, CLUBS_V2_6_outsider, CLUBS_V2_6_pending, CLUBS_V2_8, CLUBS_V2_9, H1_suggested_profiles, CLUBS_V2_12_outsider, CLUBS_V2_12_anon, SIGNUP_RL_anon_execute, SIGNUP_RL_no_table_access_anon, SIGNUP_RL_no_table_access_authenticated, EXPERIENCES_owner_insert, EXPERIENCES_owner_select, EXPERIENCES_owner_update, EXPERIENCES_b_select_a, EXPERIENCES_b_update_denied, EXPERIENCES_b_delete_denied, EXPERIENCES_anon_select_denied, EXPERIENCES_owner_delete, EXPERIENCES_cap, JOB_LISTINGS_authenticated_select, JOB_LISTINGS_authenticated_insert_denied, JOB_LISTINGS_anon_select_denied, JOB_FIT_owner_insert_select, JOB_FIT_b_select_a_denied, JOB_PITCHES_owner_insert_select, JOB_PITCHES_b_select_a_denied, JOB_SAVES_owner_insert_select, JOB_SAVES_b_select_a_denied, JOB_SAVES_b_delete_a_denied, JOB_SAVES_owner_delete, JOB_SAVES_anon_select_denied, REFERRAL_JOINED_owner_select, REFERRAL_JOINED_b_select_denied, P001_guard_sub_event_frozen, P001_guard_is_bot_frozen, P001_suggested_private_masked, P001_suggested_limit_clamped, P001_suggested_suspended_hidden, P001_group_add_requires_member_follow, P001_group_readd_after_leave_denied, P001_post_hidden_by_suspension_frozen, RESUME_FIELDS_private_nulled, RESUME_FIELDS_anon_public_read.', v_failed; end if; end $$; From 3ca01d44193b6397cd40fe33e266f0d9a799029f Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:09:51 -0400 Subject: [PATCH 17/22] Derive portfolio skills from published projects. --- lib/portfolio/skills.test.ts | 44 ++++++++++++++++++++++++++++++++++++ lib/portfolio/skills.ts | 31 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 lib/portfolio/skills.test.ts create mode 100644 lib/portfolio/skills.ts diff --git a/lib/portfolio/skills.test.ts b/lib/portfolio/skills.test.ts new file mode 100644 index 00000000..26d99fdb --- /dev/null +++ b/lib/portfolio/skills.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { SKILLS_CAP, deriveSkills } from "./skills"; + +describe("deriveSkills", () => { + it("returns nothing for no projects", () => { + expect(deriveSkills([])).toEqual([]); + }); + + it("dedupes case-insensitively, keeps the first spelling, counts once per project", () => { + expect(deriveSkills([{ technologies: ["React", "react", "REACT"] }])).toEqual(["React"]); + }); + + it("orders by number of projects using the skill", () => { + expect( + deriveSkills([ + { technologies: ["Go"] }, + { technologies: ["TypeScript", "Go"] }, + { technologies: ["typescript", "Go"] }, + ]) + ).toEqual(["Go", "TypeScript"]); + }); + + it("breaks ties by first appearance", () => { + expect(deriveSkills([{ technologies: ["Rust", "Python"] }, { technologies: ["Python", "Rust"] }])).toEqual([ + "Rust", + "Python", + ]); + }); + + it("ignores blank entries and trims labels", () => { + expect(deriveSkills([{ technologies: [" Next.js ", "", " "] }])).toEqual(["Next.js"]); + }); + + it("caps at SKILLS_CAP by default", () => { + const technologies = Array.from({ length: 15 }, (_, i) => `Tech${i}`); + const skills = deriveSkills([{ technologies }]); + expect(skills).toHaveLength(SKILLS_CAP); + expect(skills).toEqual(technologies.slice(0, 12)); + }); + + it("honors a custom cap", () => { + expect(deriveSkills([{ technologies: ["A", "B", "C", "D"] }], 2)).toHaveLength(2); + }); +}); diff --git a/lib/portfolio/skills.ts b/lib/portfolio/skills.ts new file mode 100644 index 00000000..072f7d95 --- /dev/null +++ b/lib/portfolio/skills.ts @@ -0,0 +1,31 @@ +export const SKILLS_CAP = 12; + +/** + * Skills for a resume view, derived from PUBLISHED projects only (caller + * passes published projects). Case-insensitive dedupe keeps the first-seen + * spelling. Order: number of projects using it (desc), then first appearance. + * A technology repeated inside one project counts once for that project. + */ +export function deriveSkills( + projects: ReadonlyArray<{ technologies: readonly string[] }>, + cap: number = SKILLS_CAP +): string[] { + const seen = new Map(); + let order = 0; + for (const project of projects) { + const inProject = new Set(); + for (const raw of project.technologies) { + const label = raw.trim(); + const key = label.toLowerCase(); + if (!label || inProject.has(key)) continue; + inProject.add(key); + const hit = seen.get(key); + if (hit) hit.count += 1; + else seen.set(key, { label, count: 1, first: order++ }); + } + } + return [...seen.values()] + .sort((a, b) => b.count - a.count || a.first - b.first) + .slice(0, cap) + .map((skill) => skill.label); +} From 250a0b2b7d8ab139aa4b74275ad0f1abfddf8df4 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:10:37 -0400 Subject: [PATCH 18/22] Validate the headline and profile links on the server. --- lib/portfolio/owner.test.ts | 56 +++++++++++++++++++++++++++++++++++++ lib/portfolio/owner.ts | 35 +++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/lib/portfolio/owner.test.ts b/lib/portfolio/owner.test.ts index 165586e8..92cb015c 100644 --- a/lib/portfolio/owner.test.ts +++ b/lib/portfolio/owner.test.ts @@ -3,6 +3,7 @@ import { PORTFOLIO_UNAVAILABLE, type PortfolioQueryError } from "./errors"; import { createProject, parseProjectWrite, + parseResumeFields, publishedSections, reorderProjects, saveOwnerSettings, @@ -419,3 +420,58 @@ describe("publishedSections", () => { expect(publishedSections({ ...off, allow_indexing: true })).toEqual([]); }); }); + +describe("parseResumeFields", () => { + const empty = { headline: "", github_url: "", linkedin_url: "", website_url: "" }; + + it("maps all empty strings to null", () => { + expect(parseResumeFields(empty)).toEqual({ + ok: true, + data: { headline: null, github_url: null, linkedin_url: null, website_url: null }, + }); + }); + + it("echoes a valid set", () => { + const valid = { + headline: "CS junior building dev tools", + github_url: "https://github.com/ada", + linkedin_url: "https://www.linkedin.com/in/ada", + website_url: "https://ada.dev", + }; + expect(parseResumeFields(valid)).toEqual({ ok: true, data: valid }); + }); + + it("accepts the bare linkedin.com host", () => { + const result = parseResumeFields({ ...empty, linkedin_url: "https://linkedin.com/in/ada" }); + expect(result.ok && result.data.linkedin_url).toBe("https://linkedin.com/in/ada"); + }); + + it("rejects a headline over 120 characters", () => { + const result = parseResumeFields({ ...empty, headline: "x".repeat(121) }); + expect(result.ok).toBe(false); + expect(!result.ok && !result.unavailable && result.error).toContain("120"); + }); + + it("rejects a non-GitHub host for the GitHub link", () => { + const result = parseResumeFields({ ...empty, github_url: "https://gitlab.com/ada" }); + expect(result.ok).toBe(false); + expect(!result.ok && !result.unavailable && result.error).toBe("GitHub link must be a github.com URL."); + }); + + it("rejects a lookalike LinkedIn host", () => { + expect(parseResumeFields({ ...empty, linkedin_url: "https://linkedin.com.evil.io/x" }).ok).toBe(false); + }); + + it("rejects a javascript: website", () => { + expect(parseResumeFields({ ...empty, website_url: "javascript:alert(1)" }).ok).toBe(false); + }); + + it("rejects userinfo in a link", () => { + expect(parseResumeFields({ ...empty, github_url: "https://user:pw@github.com/ada" }).ok).toBe(false); + }); + + it("trims surrounding whitespace", () => { + const result = parseResumeFields({ ...empty, website_url: " https://ada.dev " }); + expect(result.ok && result.data.website_url).toBe("https://ada.dev"); + }); +}); diff --git a/lib/portfolio/owner.ts b/lib/portfolio/owner.ts index 484bd2ec..eb9008ed 100644 --- a/lib/portfolio/owner.ts +++ b/lib/portfolio/owner.ts @@ -14,6 +14,7 @@ import { STUDY_MODES, openToError, studyModeError, + httpUrlError, portfolioPublishConflict, projectWriteError, sectionOrderError, @@ -388,4 +389,38 @@ export function parseStudyMode(value: unknown): PortfolioResult; + +export function parseResumeFields(input: Record): PortfolioResult { + const text = (value: unknown) => (typeof value === "string" ? value.trim() : ""); + const headline = text(input.headline); + if (headline.length > HEADLINE_MAX) return fail(`Headline must be ${HEADLINE_MAX} characters or fewer.`, 400); + const out: ResumeFields = { headline: headline || null, github_url: null, linkedin_url: null, website_url: null }; + for (const { key, label, hosts } of RESUME_LINKS) { + const value = text(input[key]); + if (!value) continue; + const error = httpUrlError(label, value); + if (error) return fail(error, 400); + const host = new URL(value).hostname.toLowerCase(); + if (hosts && !(hosts as readonly string[]).includes(host)) { + return fail(`${label} must be a ${hosts[0]} URL.`, 400); + } + out[key] = value; + } + return { ok: true, data: out }; +} + export { isPortfolioSchemaMissing, failUnavailable, portfolioPublishConflict }; From a6aae7c126521c93e3e4aff84dec36b86863db1f Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:10:55 -0400 Subject: [PATCH 19/22] Save the headline and profile links from the edit form. --- app/(app)/profile/edit/actions.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/app/(app)/profile/edit/actions.ts b/app/(app)/profile/edit/actions.ts index 69b8137d..883a7243 100644 --- a/app/(app)/profile/edit/actions.ts +++ b/app/(app)/profile/edit/actions.ts @@ -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 @@ -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 @@ -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); @@ -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") From 127b4ede33a7aa2667f3395eab6a72df4a7e7c46 Mon Sep 17 00:00:00 2001 From: Dev Goswami Date: Wed, 30 Sep 2026 01:11:30 -0400 Subject: [PATCH 20/22] Add headline and link inputs to the edit profile form. --- app/(app)/profile/edit/page.tsx | 6 +++++- components/profile/EditProfileForm.tsx | 30 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/(app)/profile/edit/page.tsx b/app/(app)/profile/edit/page.tsx index b4491624..939a99ec 100644 --- a/app/(app)/profile/edit/page.tsx +++ b/app/(app)/profile/edit/page.tsx @@ -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(); @@ -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 diff --git a/components/profile/EditProfileForm.tsx b/components/profile/EditProfileForm.tsx index cb6bdf99..12bd595a 100644 --- a/components/profile/EditProfileForm.tsx +++ b/components/profile/EditProfileForm.tsx @@ -31,6 +31,10 @@ export type EditInitial = { is_pro: boolean; pro_until: string | null; profile_theme: string | null; + headline: string | null; + github_url: string | null; + linkedin_url: string | null; + website_url: string | null; }; const label = "block text-sm font-medium text-[var(--ink)]"; @@ -150,6 +154,13 @@ export default function EditProfileForm({ initial }: { initial: EditInitial }) { defaultValue={initial.display_name ?? ""} placeholder="Your name" className={field} /> +
+ + +

One line under your name on your portfolio.

+
+