Skip to content

Team leaders pick one mentor per track, gated on the booking link - #88

Merged
thomgabriel merged 3 commits into
mainfrom
feat/mentorship-schedule
Sep 4, 2026
Merged

Team leaders pick one mentor per track, gated on the booking link#88
thomgabriel merged 3 commits into
mainfrom
feat/mentorship-schedule

Conversation

@draaujpeg

Copy link
Copy Markdown
Collaborator

The mentorship day runs on the mentors' own scheduling pages, and those links
are bearer tokens: whoever holds one takes a slot. Without a gate, people with
no team fill the agenda of the teams actually competing.

So the platform does two things and no more. It reveals a mentor's link only to
the leader of a formed team, and only for the mentor that team already chose;
and it records the choice so the organizers can reconcile against the external
agendas on the day. It does not book anything, and it cannot see whether anyone
finished booking — the copy on the page says so plainly.

Mentors come in two tracks. A team may claim one of each: none, técnico only,
negócios only, or both — never two of the same.

The rule that shapes the design

booking_url leaves the database in exactly one shape: the booking a team
already made, and only to that team's leader. The catalog mentorship_board
returns carries name, specialty and track and never the link, so a leader
cannot lift every URL out of the page payload before choosing anything. An
earlier draft of this put the link in the catalog and would have handed the
whole list to anyone who opened devtools, with an empty ledger to show for it.

Both tables run RLS with no policies at all — the submission_ratings
posture already documented in CLAUDE.md. A row policy protects rows, not
columns, and a leader allowed to select a mentor row is a leader allowed to
select=booking_url. Every member-facing read and write goes through the two
SECURITY DEFINER functions instead. Note 00009's default privileges hand
every new table select to anon, so the grant that matters here is a
revoke, not a grant.

One index carries the product rule

create unique index mentorship_bookings_one_per_track
  on public.mentorship_bookings (team_id, track) where released_at is null;

book_mentorship resolves the team from the mentor's edition rather than
anything the client sends, so a leader who leads teams in two editions cannot
spend the wrong one's slot, and copies track from the mentor row inside the
same transaction. for update of t serialises a double click; the index is the
real backstop and turns a unique_violation into already_booked.

An RPC rather than a service-role server action, per CLAUDE.md's rule for
member-facing cross-table writes: the action shape would need a read-then-insert
that a double click passes twice, and would put service_role in a participant
path for a flow with a natural RPC shape.

Deliberately absent

No capacity counting, no publish flag, no booking window — the mentorship is a
single day and the external agenda is the real allocator. available is the
manual off switch for a mentor whose agenda filled, which is a different thing
from counting. Undo stays with the organizers: releaseBooking is a soft
release, so the ledger keeps the history and the track reopens on commit.

Accepted risk, raised and decided: without self-service switching, every team
that picks a mentor whose agenda is full becomes a manual request to an
organizer mid-event. The available toggle is the mitigation — one tap stops
the incident repeating.

Entry point

A card in the dashboard sidebar, not a sixth nav tab: the segmented control is
overflow-x-auto with a hidden scrollbar, and five labels already overflow a
360px phone. dashboard/page.tsx already has the team snapshot in scope, so the
card costs no extra round trip.

What is in here

  • 00059_mentorship.sql — two tables, the partial unique index, the revokes,
    RLS with no policies, mentorship_board and book_mentorship, and a seed of
    the five mentors who have links, so the admin screen is not on the critical
    path the night before.
  • /h/[slug]/mentorship — four states, all decided by the pure mentorshipView.
  • /admin/h/[slug]/mentorship — two panels with inline edit and bulk paste
    (Nome | Especialidade | link, one per line), the availability toggle, and the
    bookings ledger with a release button.
  • 13 unit tests over the pure helpers; the RPCs are exercised manually, per the
    house convention.

Verification

npm run build and npm test pass (159 tests). The check that matters before
this ships is the leak test: as a leader, call mentorship_board and confirm no
catalog mentor carries a booking_url key; as a non-leader member, confirm the
link is absent from the RSC payload.

Before the event

  • Marcelo Barella (técnico, AI e programação) is not seeded — his link did
    not exist yet. Add him from the admin screen when it arrives. Until then the
    técnico track has two mentors against three on negócios.
  • The kind='mentoria' content row from seed 00017 still reads "Horários e
    mentores a confirmar" and shows in Conteúdos to every participant — two
    surfaces named "Mentorias" on the day unless it is unpublished or rewritten.
  • page_md (00044:26) still describes mentorship as a WhatsApp thing.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hackathon-platform Ready Ready Preview Sep 4, 2026 5:57pm UTC

Request Review

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @draaujpeg's task in 4m 16s —— View job


Review complete

Verdict: needs changes — one authorization gap, otherwise solid.

Checked: the migration (RLS-with-no-policies posture, revokes, the two SECURITY DEFINER RPCs, the partial unique index), every server action under admin/h/[slug]/mentorship (all filter on gate.hackathon.id as well as row id), the member-facing action/RPC path, the pure helpers + their tests, and the dashboard/nav integration. npm run build/npm test weren't run here (no node_modules, network install needs approval) — reviewed statically instead.

  1. book_mentorship skips the registration check that mentorship_board explicitly adds for the same reason. mentorship_board checks hackathon_registrations before returning data, with a comment explaining why: it's callable directly over PostgREST, bypassing the page's isRegistrationComplete redirect. book_mentorship has no equivalent check. Since create_team_with_leader also never checks registration, a signed-up user can create a team and call book_mentorship directly to claim a mentor slot without ever registering for the edition — the exact "people with no team fill the agenda" abuse case the PR is designed to prevent, just from a different angle. Left an inline comment with a suggested fix on supabase/migrations/00059_mentorship.sql:203-214.

Everything else checked out: every admin mutation scopes on edition id + row id, member writes go through the RPC (not a service-role action) as CLAUDE.md prescribes, booking_url never appears in the catalog payload (confirmed in both the SQL and mentorship.ts's types/tests), URL validation rejects non-http(s) protocols, and the soft-release/soft-delete paths correctly exclude released/deleted rows from counts and the active-booking unique constraint.

Branch: feat/mentorship-schedule

Comment on lines +203 to +214
v_user := auth.uid();
if v_user is null then raise exception 'not_authenticated'; end if;

select m.id, m.hackathon_id, m.track
into v_mentor
from public.hackathon_mentors m
join public.hackathons h on h.id = m.hackathon_id and h.status <> 'draft'
where m.id = p_mentor_id
and m.deleted_at is null
and m.available;

if v_mentor.id is null then raise exception 'mentor_not_found'; end if;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

book_mentorship never checks hackathon_registrations for the caller, unlike mentorship_board (line 120-125), whose own comment explains why that check exists: "The page checks these too, but the function is callable straight over PostgREST, where nothing checked anything." That reasoning applies at least as much to the write path.

create_team_with_leader (00002_rpcs.sql) has no registration check either — it only requires auth. So a user can sign up, create a team, and call book_mentorship directly over PostgREST (bypassing the /h/[slug]/mentorship page's isRegistrationComplete redirect entirely) to claim a mentor slot without ever registering for the edition. That's exactly the abuse case the PR description calls out ("people with no team fill the agenda of the teams actually competing") — a "team" here can be created without registering.

Suggest mirroring mentorship_board's check: if not exists (select 1 from hackathon_registrations where user_id = v_user and hackathon_id = v_mentor.hackathon_id) then raise exception 'not_registered'; end if; before resolving the team.

Fix this →

draaujpeg and others added 3 commits September 4, 2026 14:52
The mentorship day runs on the mentors' own scheduling pages, and those
links are bearer tokens: whoever holds one takes a slot. Without a gate,
people with no team fill the agenda of the teams actually competing.

So the platform does two things and no more. It reveals a mentor's link
only to the leader of a formed team, and only for the mentor that team
already chose; and it records the choice so the organizers can reconcile
against the external agendas on the day.

booking_url therefore leaves the database in exactly one shape. The
catalog mentorship_board returns carries name, specialty and track — never
the link — so a leader cannot lift every URL out of the page payload
before choosing anything. Both tables run RLS with no policies at all,
the submission_ratings posture: a row policy protects rows, not columns,
and a leader allowed to select a mentor row is a leader allowed to select
booking_url. Every member-facing read and write goes through the two
SECURITY DEFINER functions instead; 00009's default privileges hand new
tables select to anon, so the grant that matters is a revoke.

Mentors come in two tracks and a team may claim one of each, which makes
the limit (team, track) rather than (team) — one partial unique index
carries the whole product rule. book_mentorship resolves the team from
the mentor's own edition, so a leader who leads teams in two editions
cannot spend the wrong one's slot, and copies the track from the mentor
row inside the same transaction.

Deliberately absent: capacity, a publish flag and a booking window. The
mentorship is a single day and the external agenda is the real allocator.
`available` is the manual off switch for a mentor whose agenda filled,
which is a different thing from counting. Undo stays with the organizers:
releasing is a soft release, so the ledger keeps the history and the
track reopens the moment it commits.

The five mentors with links are seeded here so the admin screen is not on
the critical path the night before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-in per edition, drop the seed

main already has 00059. book_mentorship now requires the same complete
registration as mentorship_board and the 00053 team RPCs. hackathons gains
mentorship_enabled (default false, on for Passo Fundo 2026) and both RPCs
raise mentorship_disabled while it is off. The five mentor rows leave the
migration: booking links are bearer tokens and git history is forever, so
production is seeded out of band.
Most editions will never run a mentorship day. editionUsesMentorship()
gates the dashboard card and the participant page (404 when off); the
admin page stays reachable with a ligar/desligar toggle, and the edition
settings form gets the same flag as a checkbox. The RPCs already refuse
while it is off (00060).
@thomgabriel
thomgabriel force-pushed the feat/mentorship-schedule branch from f7d93e9 to 6317cd4 Compare September 4, 2026 17:56
@thomgabriel

Copy link
Copy Markdown
Collaborator

Rebased on main and added two commits on top: the migration is now 00060 (main already had a 00059), book_mentorship gates on complete registration like mentorship_board, and the module is opt-in per edition via hackathons.mentorship_enabled (on for Cursor, off by default; dashboard card, member page and both RPCs respect it; toggle on the edition settings and the admin mentorship page). The five-mentor seed left the migration since booking links are bearer tokens; it will be run out of band on production at merge. 189 tests pass.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown

Claude finished @thomgabriel's task in 3m 11s —— View job


Review complete

  • Gather context (diff, changed files, prior review comments)
  • Re-check the previously flagged book_mentorship registration gap
  • Review new commits (renumber to 00060, registration gate, per-edition opt-in switch)
  • Check authorization on all admin/mentorship server actions
  • Check data safety / migration correctness
  • Post findings

Verdict: safe to merge — no findings.

This is a re-review of the pushed changes since the last automated pass. The previously flagged gap is fixed: book_mentorship (supabase/migrations/00060_mentorship.sql:235-241) now checks hackathon_registrations (luma_confirmed_at/terms_accepted_at) before resolving the team, mirroring mentorship_board's check and matching isRegistrationComplete() exactly — no drift between the page gate and the RPC gate.

The new commits also add an opt-in hackathons.mentorship_enabled switch (default false). Checked:

  • Both RPCs raise mentorship_disabled when the flag is off, and editionUsesMentorship() (requires the flag and platform teams) gates both /h/[slug]/mentorship (notFound()) and the dashboard card, so the RPC's exception path is never hit through normal navigation — no unwrap()-throws-500 risk.
  • setMentorshipEnabled and every other mentorship admin action goes through requireEditionAdminBySlug and filters mutations on gate.hackathon.id as well as row id (create/update/delete mentors, release booking).
  • The new checkbox field in EditionForm/updateEdition correctly maps an absent FormData key (unchecked box) to false, not skipped.
  • Migration is additive and re-runnable (add column if not exists), no collision at 00060, no dropped clauses on the redefined functions vs. the prior draft.
  • Member-facing write still goes through the RPC via a user-scoped client (src/app/(app)/h/[slug]/mentorship/actions.ts), not a service-role action.
  • booking_url still never appears in the catalog payload; confirmed in both the SQL and mentorship.ts types/tests.

npm run build/npm test weren't run (no node_modules in this environment, install needs network approval) — reviewed statically, same limitation as the last pass.

Branch: feat/mentorship-schedule

@thomgabriel
thomgabriel merged commit 1731d1e into main Sep 4, 2026
4 checks passed
@draaujpeg
draaujpeg deleted the feat/mentorship-schedule branch September 4, 2026 19:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants