feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405) - #485

Merged
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers
Jul 30, 2026
Merged

feat: B10 stragglers — manual add-concept (#330) + rooms semantics (#405)#485
AndresL230 merged 4 commits into
mainfrom
feat/b10-stragglers

Conversation

@AndresL230

@AndresL230AndresL230 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Part of #330 and part of #405 (both left open per closure policy — Andres verifies the UI surfaces and closes). Completes B10 alongside the #323 audit (closed with follow-ups #481#484).

#330 — manually add a concept

  • graph_service.add_node: thin wrapper over apply_graph_update (dedup/edges/analytics-refresh stay in one place); resolves the anchor id to its NAME (the edge machinery is name-keyed); reports merge-vs-create; returns the canonical row. POST /api/graph/{user_id}/nodes (require_self, 422 on blank name; course_id required — NULL-course dedup is always-distinct).
  • Learn's rail composer now actually PERSISTS (it was client-only — concepts vanished on refresh): optimistic node kept, canonical id swapped via pure unit-tested helpers, rollback + toast on failure, merge toast on dedup.
  • Tree gains '+ Add concept' (single-course filter only; anchors to the selected node). Testids registered doc+eslint; Tree.tsx entered the lint enforcement list with its 8 pre-existing untagged elements baselined per the documented recipe.
  • Journey tree-add-concept.spec.ts: persist → render (a11y list by node id) → case-drifted re-add merges, original row survives.

#405 — rooms semantics (Andres's decision: real ownership + public rooms)

  • Migration 0038: owner_id := created_by backfill + NOT NULL (ownership is real and transferable later; created_by stays the immutable creator record), is_public DEFAULT false NOT NULL, updated_at DEFAULT now() + backfill.
  • create_room populates owner/topic/course/is_public; kick authorization keys on owner_id; membership changes touch updated_at.
  • Public surface: GET /api/social/public-rooms (explicit projection — cannot leak invite_code) + invite-less POST /api/social/public-rooms/{id}/join (403 private, idempotent). Frontend: create form gains topic/course/Public, sidebar shows topic + Public badge, and a Public-rooms discovery list with one-click Join. A fuller discovery surface stays future work per the decision.
  • Journey room-public.spec.ts: UI create with topic+public → DB semantics assert → second user joins invite-less → membership row.

Tests

Backend: 17 new (add_node service/route ×8, rooms semantics ×9); suite 1525 passed; ruff clean. Frontend: 397 passed / tsc / lint 0 errors (rebased over #480's suppressions prune — converged). Two promoted journeys. Full local e2e cycle before merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added public study rooms with optional topics and courses.
    • Users can discover and join public rooms without invite codes.
    • Added manual concept creation for selected courses, including duplicate merging and optional relationships.
    • Added public-room labels, member counts, and discovery controls.
  • Bug Fixes

    • Improved room ownership permissions and membership update tracking.
    • Added safer defaults and validation for room data.
  • Tests

    • Added coverage for public rooms, concept creation, merging, and related user flows.

AndresL230and others added 2 commits July 30, 2026 12:00
- graph_service.add_node: thin wrapper over apply_graph_update (dedup,
edges, analytics refresh stay in one place); resolves the anchor id to
its NAME (the edge machinery is name-keyed); reports merge-vs-create
via a pre-check; returns the canonical row.
- POST /api/graph/{user_id}/nodes (require_self; 422 on blank name/course;
course_id required — NULL-course dedup is always-distinct).
- Learn's rail composer now PERSISTS (was client-only, vanished on
refresh): optimistic node kept, canonical id swapped in via pure
reconcile helpers (lib/graphOptimistic, unit-tested), rollback + toast
on failure, merge toast on dedup.
- Tree gains the '+ Add concept' composer (single-course filter only),
anchored to the selected node when it belongs to the course; reloads
from DB truth; graph-add-concept* testids registered in doc + eslint
array (Tree.tsx entered the enforcement list with zero baselined debt).
- Journey e2e/tree-add-concept.spec.ts: persist → render (a11y list by
node id) → case-drifted re-add merges, original row survives.
Part of #330.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Migration 0038: backfill owner_id := created_by (+ NOT NULL) — real,
transferable ownership; created_by stays the immutable creator record.
is_public DEFAULT false NOT NULL (null was neither); updated_at DEFAULT
now() + backfill from created_at.
- create_room populates owner_id/topic/course/is_public (CreateRoomBody
widened); kick authorization keys on owner_id; membership changes touch
updated_at (_touch_room — message traffic left to the 0033 realtime flow).
- Public surface: GET /api/social/public-rooms (explicit projection — the
payload cannot carry invite_code) + invite-less
POST /api/social/public-rooms/{id}/join (403 for private, idempotent).
- Frontend: create form gains topic/course/Public fields; sidebar renders
topic + Public badge; a Public-rooms discovery list with one-click Join.
Testids registered in the doc; Tree.tsx's pre-existing untagged elements
baselined per the documented recipe.
- Journey room-public.spec.ts: UI create with topic+public → DB semantics
assert → second user context joins invite-less → membership row.
Part of #405.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@supabase

supabaseBot commented Jul 30, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 30, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingc712274Commit Preview URL

Branch Preview URL
Jul 30 2026, 07:23 PM

@coderabbitai

coderabbitaiBot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AndresL230, you've reached your PR review limit, so we couldn't start this review.

Next review available in:39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fea22128-a76f-45a1-b8de-e288ac153206

📥 Commits

Reviewing files that changed from the base of the PR and between 60cbdcc and c712274.

📒 Files selected for processing (13)
  • backend/db/seed_local_rich.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/types.ts
📝 Walkthrough

Walkthrough

This PR adds persisted manual graph-concept creation with optimistic reconciliation, and extends social rooms with ownership, metadata, public discovery, invite-less joining, and membership timestamp updates. It also adds backend, frontend, API, and end-to-end coverage.

Changes

Manual graph concepts

Layer / File(s)Summary
Graph node creation backend
backend/routes/graph.py, backend/services/graph_service.py
Adds an authenticated node-creation route and service logic for validation, normalized deduplication, optional anchor edges, and canonical read-back.
Optimistic graph persistence
frontend/src/lib/api.ts, frontend/src/lib/graphOptimistic.ts, frontend/src/components/screens/{Learn,Tree}.tsx
Persists concepts through the API, reconciles temporary nodes and edges with canonical data, rolls back failures, and adds the course-scoped composer UI.
Graph flow validation and test hooks
backend/tests/test_graph_add_node.py, frontend/src/lib/*graph*.test.ts, frontend/e2e/tree-add-concept.spec.ts, docs/frontend-testids.md, frontend/eslint*
Tests creation, merging, authorization, optimistic state behavior, persistence, rendering, and test-id enforcement.

Public rooms

Layer / File(s)Summary
Room semantics and API contracts
backend/db/migrations/0038_rooms_semantics.sql, backend/models/__init__.py, frontend/src/lib/{api,types}.ts
Backfills and constrains room ownership/public fields, adds room metadata and public-join request models, and exposes typed public-room API wrappers.
Room ownership and public endpoints
backend/routes/social.py
Persists room ownership and metadata, lists public rooms without invite codes, supports idempotent public joining, authorizes kicks by owner, and updates updated_at after membership changes.
Public room creation and discovery UI
frontend/src/components/screens/Social.tsx, docs/frontend-testids.md
Adds topic/course/public controls, public-room discovery and joining, and public-room sidebar indicators.
Room behavior validation
backend/tests/test_social_rooms.py, frontend/src/lib/socialApi.test.ts, frontend/e2e/room-public.spec.ts
Tests defaults, ownership authorization, public filtering, API payloads, invite-less joining, and persisted membership.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Tree
participant addGraphNode
participant create_node
participant add_node
participant graph_nodes
Tree->>addGraphNode: submit concept and course
addGraphNode->>create_node: POST node request
create_node->>add_node: validated node data
add_node->>graph_nodes: create or merge concept
graph_nodes-->>add_node: canonical node
add_node-->>create_node: node and merge flag
create_node-->>addGraphNode: response
addGraphNode-->>Tree: reconcile optimistic graph state
Loading
sequenceDiagram
participant Social
participant listPublicRooms
participant list_public_rooms
participant joinPublicRoom
participant join_public_room
participant room_members
Social->>listPublicRooms: request discoverable rooms
listPublicRooms->>list_public_rooms: GET public rooms
list_public_rooms-->>Social: metadata without invite codes
Social->>joinPublicRoom: select room
joinPublicRoom->>join_public_room: POST user_id
join_public_room->>room_members: insert membership if absent
join_public_room-->>Social: joined room response
Loading

Possibly related PRs

  • SaplingLearn/Sapling#53: The new graph node creation path uses the course-scoped graph update and node model behavior introduced there.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 21.43% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title is concise and accurately covers the two main changes: manual add-concept and room semantics.
Description check✅ PassedThe description covers the main changes and testing, though it doesn't follow the repo template headings exactly.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/b10-stragglers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
frontend/src/components/screens/Social.tsx (1)

36-41: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Local Room type overlaps with PublicRoom from types.ts.

Room here duplicates several fields (topic, course, is_public, created_by) that also exist on the PublicRoom interface in frontend/src/lib/types.ts. Not blocking, but worth considering a shared base type to avoid the two drifting apart as more room fields are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 36 - 41, Replace the
local Room type in Social.tsx with the shared PublicRoom type from
frontend/src/lib/types.ts, or derive Room from it if the component needs
additional fields. Remove duplicated room-field declarations and update affected
usages to rely on the shared definition.
backend/db/migrations/0038_rooms_semantics.sql (1)

19-19: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

SET NOT NULL takes an ACCESS EXCLUSIVE lock for the full validation scan.

Both ALTER COLUMN owner_id SET NOT NULL and ALTER COLUMN is_public SET NOT NULL block all reads/writes on rooms for the duration of the table scan used to validate the constraint. The static-analysis hint (squawk) flags this; the safer pattern is ADD CONSTRAINT ... CHECK (col IS NOT NULL) NOT VALID followed by a separate VALIDATE CONSTRAINT, which only takes a brief lock and allows concurrent reads/writes during validation. Given rooms is presumably small today, impact is likely minor, but this is the correct pattern going forward as the table grows.

♻️ Safer non-blocking pattern
-ALTER TABLE rooms ALTER COLUMN owner_id SET NOT NULL;+ALTER TABLE rooms ADD CONSTRAINT rooms_owner_id_not_null CHECK (owner_id IS NOT NULL) NOT VALID;+ALTER TABLE rooms VALIDATE CONSTRAINT rooms_owner_id_not_null;

Same pattern applies to is_public.

Also applies to: 23-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/db/migrations/0038_rooms_semantics.sql` at line 19, Replace the
direct SET NOT NULL operations for rooms.owner_id and rooms.is_public with named
CHECK constraints using NOT VALID, then validate each constraint in separate
statements. Preserve the enforced non-null behavior while using the lower-lock
validation pattern for both columns.

Source: Linters/SAST tools

backend/routes/social.py (1)

65-88: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

N+1 query for member counts in public room listing.

For every public room, this issues a separate room_members select (line 71-73) to compute member_count. This scales linearly with the number of public rooms per request; as public-room discovery grows, this becomes a hot-path performance concern.

♻️ Batch the membership counts
- rooms = table("rooms").select(- "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",- filters={"is_public": "eq.true"},- ) or []- out = []- for room in rooms:- members = table("room_members").select(- "user_id", filters={"room_id": f"eq.{room['id']}"},- ) or []+ rooms = table("rooms").select(+ "id,name,topic,course,owner_id,created_by,created_at,updated_at,is_public",+ filters={"is_public": "eq.true"},+ ) or []+ room_ids = [r["id"] for r in rooms]+ all_members = table("room_members").select(+ "room_id", filters={"room_id": f"in.({','.join(room_ids)})"},+ ) if room_ids else []+ counts: dict = {}+ for m in all_members:+ counts[m["room_id"]] = counts.get(m["room_id"], 0) + 1+ out = []+ for room in rooms:+ members_count = counts.get(room["id"], 0)

Adjust to whatever batching/in. filter syntax table() supports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/routes/social.py` around lines 65 - 88, Replace the per-room
room_members query in the public room listing with a batched membership lookup
using a single in-style filter for all public room IDs, then aggregate counts by
room_id and use those counts when building each payload in the existing rooms
loop. Preserve zero counts for rooms with no members and the current explicit
response projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/services/graph_service.py`:
- Around line 604-625: Make normalized concept deduplication atomic by adding an
append-only database migration with a uniqueness constraint/index on the
normalized concept key, then update the graph-node creation flow around
apply_graph_update to upsert against that constraint rather than relying on the
pre-check. Derive already_existed from the atomic upsert result, preserving the
existing edge creation behavior for newly inserted nodes.
In `@backend/tests/test_graph_add_node.py`:
- Around line 30-42: Replace the local _graph_nodes_mock MagicMock factory with
the shared mocked-Supabase fixture from tests/conftest.py, configuring its
graph_nodes.select behavior with the required select_side_effect and preserving
empty results for other tables through the fixture’s existing setup.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 1239-1241: Update the member-count label in the sidebar room
rendering to use the same singular/plural logic as PublicRoomsList, displaying
“member” for a count of one and “members” otherwise while preserving the
existing topic prefix.
In `@frontend/src/lib/graphOptimistic.ts`:
- Around line 45-50: Update the canonical-node reconciliation branch in the
graph optimistic helper to copy the backend-normalized concept name into the
optimistic node, alongside the existing canonical id and mastery fields. Add a
test case in the graph optimistic tests covering an optimistic name with extra
spacing and asserting the reconciled node uses the canonical normalized name.
---
Nitpick comments:
In `@backend/db/migrations/0038_rooms_semantics.sql`:
- Line 19: Replace the direct SET NOT NULL operations for rooms.owner_id and
rooms.is_public with named CHECK constraints using NOT VALID, then validate each
constraint in separate statements. Preserve the enforced non-null behavior while
using the lower-lock validation pattern for both columns.
In `@backend/routes/social.py`:
- Around line 65-88: Replace the per-room room_members query in the public room
listing with a batched membership lookup using a single in-style filter for all
public room IDs, then aggregate counts by room_id and use those counts when
building each payload in the existing rooms loop. Preserve zero counts for rooms
with no members and the current explicit response projection.
In `@frontend/src/components/screens/Social.tsx`:
- Around line 36-41: Replace the local Room type in Social.tsx with the shared
PublicRoom type from frontend/src/lib/types.ts, or derive Room from it if the
component needs additional fields. Remove duplicated room-field declarations and
update affected usages to rely on the shared definition.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e864ff8a-fd63-4a93-9af3-265960cf6910

📥 Commits

Reviewing files that changed from the base of the PR and between 47950e5 and 60cbdcc.

📒 Files selected for processing (21)
  • backend/db/migrations/0038_rooms_semantics.sql
  • backend/models/__init__.py
  • backend/routes/graph.py
  • backend/routes/social.py
  • backend/services/graph_service.py
  • backend/tests/test_graph_add_node.py
  • backend/tests/test_social_rooms.py
  • docs/frontend-testids.md
  • frontend/e2e/room-public.spec.ts
  • frontend/e2e/tree-add-concept.spec.ts
  • frontend/eslint-suppressions.json
  • frontend/eslint.config.mjs
  • frontend/src/components/screens/Learn.tsx
  • frontend/src/components/screens/Social.tsx
  • frontend/src/components/screens/Tree.tsx
  • frontend/src/lib/api.ts
  • frontend/src/lib/graphApi.test.ts
  • frontend/src/lib/graphOptimistic.test.ts
  • frontend/src/lib/graphOptimistic.ts
  • frontend/src/lib/socialApi.test.ts
  • frontend/src/lib/types.ts

Comment on lines +604 to +625
pre = table("graph_nodes").select("id,concept_name", filters=scope) or []
already_existed = any(
_normalize_concept(r.get("concept_name") or "") == norm for r in pre
)

update: dict = {
"new_nodes": [
{"concept_name": name, "course_id": course_id, "initial_mastery": initial_mastery},
],
}
if anchor_node_id:
anchor_rows = table("graph_nodes").select(
"id,concept_name",
filters={"id": f"eq.{anchor_node_id}", "user_id": f"eq.{user_id}"},
) or []
anchor_name = (anchor_rows[0].get("concept_name") if anchor_rows else "") or ""
if anchor_name and _normalize_concept(anchor_name) != norm:
update["new_edges"] = [
{"source": anchor_name, "target": name, "relationship_type": "related", "strength": 0.5},
]

apply_graph_update(user_id, update, course_id=course_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make normalized concept deduplication atomic.

The pre-check races with the raw-name upsert: concurrent Recursion and recursion requests both miss, do not conflict on concept_name, and create duplicate normalized nodes. Enforce the normalized key with a database uniqueness constraint in an append-only migration, upsert against it, and derive already_existed from that atomic result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/services/graph_service.py` around lines 604 - 625, Make normalized
concept deduplication atomic by adding an append-only database migration with a
uniqueness constraint/index on the normalized concept key, then update the
graph-node creation flow around apply_graph_update to upsert against that
constraint rather than relying on the pre-check. Derive already_existed from the
atomic upsert result, preserving the existing edge creation behavior for newly
inserted nodes.

Source: Coding guidelines

Comment on lines +30 to +42
def _graph_nodes_mock(select_side_effect):
"""One cached graph_nodes mock whose .select returns follow the given
sequence; other tables return empty lists."""
nodes = MagicMock()
nodes.select.side_effect = select_side_effect

def factory(name):
if name == "graph_nodes":
return nodes
other = MagicMock()
other.select.return_value = []
return other
return factory, nodes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared Supabase test fixture.

This local MagicMock factory bypasses the repository’s shared mocked-Supabase setup, allowing service-test contracts to drift. Configure the shared fixture instead.

As per coding guidelines, “Backend tests belong under backend/tests/ and run with pytest; use the shared fixtures in tests/conftest.py for mocked Supabase and Gemini services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/tests/test_graph_add_node.py` around lines 30 - 42, Replace the local
_graph_nodes_mock MagicMock factory with the shared mocked-Supabase fixture from
tests/conftest.py, configuring its graph_nodes.select behavior with the required
select_side_effect and preserving empty results for other tables through the
fixture’s existing setup.

Source: Coding guidelines

Comment on lines +1239 to +1241
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count} members
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sidebar always shows "members" even for a single member.

Right after room creation, a room has exactly one member (the creator), so this renders "1 members." The sibling PublicRoomsList component (line 246) already handles the singular/plural correctly — reuse that logic here.

🐛 Proposed fix
 <div style={{ fontSize: 11, color: "var(--text-muted)" }}>
- {r.topic ? `${r.topic} · ` : ""}{r.member_count} members+ {r.topic ? `${r.topic} · ` : ""}{r.member_count} member{r.member_count === 1 ? "" : "s"}
</div>
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}members
</div>
<divstyle={{fontSize: 11,color: "var(--text-muted)"}}>
{r.topic ? `${r.topic} · ` : ""}{r.member_count}member{r.member_count===1 ? "" : "s"}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/screens/Social.tsx` around lines 1239 - 1241, Update
the member-count label in the sidebar room rendering to use the same
singular/plural logic as PublicRoomsList, displaying “member” for a count of one
and “members” otherwise while preserving the existing topic prefix.

Comment threadfrontend/src/lib/graphOptimistic.ts
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Code review

Found 1 issue:

  1. Migration 0038 makes rooms.owner_id NOT NULL with no default, but the rich seed never sets it — a from-empty replay (make e2e-up, and CI's e2e.yml, which both run migrations then seed) fails the first rooms INSERT and aborts the whole stack boot. Found independently by two reviewers and verified directly. Masked on any existing local volume, where the upsert becomes an UPDATE.

UPDATE rooms SET is_public = FALSE WHERE is_public IS NULL;
ALTERTABLE rooms ALTER COLUMN is_public SET DEFAULT FALSE;
ALTERTABLE rooms ALTER COLUMN is_public SETNOT NULL;
UPDATE rooms SET updated_at = created_at WHERE updated_at IS NULL;
ALTERTABLE rooms ALTER COLUMN updated_at SET DEFAULT now();

defseed_rooms() ->None:
forroom_id, name, invite_code, created_byin_ROOMS:
h.upsert(
"rooms",
{"id": room_id, "name": name, "invite_code": invite_code, "created_by": created_by},
on_conflict="invite_code",
)

Fixed, along with eight sub-bar findings the same pass raised (all fixed regardless of score, per the standing rule):

  • join_public_room / join_room check-then-insert raced to a raw 500 on the room_members PK — now upsert(on_conflict="room_id,user_id"), with an in-flight guard on the Join button.
  • addConcept could render a duplicate node: a live tutor turn's stream-<name> placeholder wasn't absorbed by the canonical row, so the client showed two nodes for one row until the next full refetch. reconcileNodes now absorbs placeholders by normalized name (real-id nodes are left alone), and its edges are retargeted too.
  • Anchor resolution wasn't course-scoped — an anchor from another course resolved by name inside the target course and could wire the edge to the wrong node.
  • Leader/kick UI still keyed on created_by while the backend switched to owner_id; latent today (they're equal) but breaks the moment transfer ships. RoomOverviewData now declares owner_id.
  • PublicRoomsList swallowed its fetch error (a broken fetch was indistinguishable from "no public rooms" — the fix(frontend): surface the five swallowed failures — closes the #113 audit tail (#166 #185 #184 #183 #186) #463/Calendar initial-load failure is swallowed and rendered as a valid-looking empty calendar #185 smell) and never revalidated its own list after a join.
  • Temp-id collision: node-new-${Date.now()} is ms-resolution with no in-flight guard; now carries a monotonic suffix.
  • Two doc/comment corrections: the AddNodeBody rationale inverted UNIQUE NULLS NOT DISTINCT semantics, and the testids doc miscategorized Tree's baselined elements (they're the search input, five detail-panel actions and two fullscreen toggles — graph-zoom-* lives in KnowledgeGraph2D.tsx).
  • room-public.spec.ts used the wrong seeded display name for rich-user-second ("Casey" → "Sam").

One finding was documented rather than fixed: apply_graph_update's dedup folds case in Python while the 0023 constraint is case-sensitive, so two concurrent adds differing only in case can both land. That's pre-existing behavior inherited by add_node, not introduced here; the docstring no longer claims it's "UNIQUE-backed", and a citext/functional index is the real fix.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

AndresL230and others added 2 commits July 30, 2026 12:15
BLOCKER: 0038 makes rooms.owner_id NOT NULL with no default, but
seed_local_rich never set it — a from-empty replay (make e2e-up, CI's
e2e.yml) failed the first rooms INSERT and aborted the stack boot. Masked
on existing volumes, where the upsert becomes an UPDATE.
Also:
- both join paths upsert on the room_members PK instead of check-then-
insert (a double-click raced to a raw 500); Join gains an in-flight guard
- reconcileNodes absorbs live stream-<name> placeholders by normalized name
so a manual re-add can't render two nodes for one merged row; edges
retargeted too; rollback now uses dropOptimisticConcept
- add_node's anchor lookup is course-scoped (a foreign anchor resolved by
name INSIDE the target course could wire to the wrong node)
- Social's leader/kick UI keys on owner_id (backend already did);
RoomOverviewData declares it
- PublicRoomsList toasts its fetch failure instead of rendering an empty
list, and revalidates itself after a join
- optimistic temp ids carry a monotonic suffix (Date.now() is ms-resolution
and this path has no in-flight guard)
- AddNodeBody docstring no longer inverts UNIQUE NULLS NOT DISTINCT; the
dedup caveat (Python-side case folding vs a case-sensitive constraint) is
documented rather than overclaimed; testids doc lists Tree's real
baselined elements
- room-public.spec.ts uses the seeded name for rich-user-second
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The course pill's label is a prefix of the graph node's a11y activate
button ('MATH210 - Linear Algebra'), so the loose regex matched two
elements once the graph rendered — strict-mode violation, caught by the
cycle's first run of this journey.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@AndresL230