rubyforgood / rubyforgood/casa
Include the org in CASA case URLs and finish the switch away from database ids
Nobody has claimed this yet.
- Dominant language
- Ruby
- Stars
- 381
- Forks
- 542
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 47
Description
What type(s) of user does this feature affect?
All signed-in user types — volunteers, supervisors, and admins all navigate to CASA case pages. All casa admins are affected indirectly (they administer orgs, whose identifier would become part of the URL).
Description
CASA case URLs should identify a case the way humans and courts do — by organization + case number — instead of leaning on our internal database primary key.
Important: half of this is already built. CasaCase already uses FriendlyId, so today's URLs are usually already case-number based, not integer based:
app/models/casa_case.rb:26—friendly_id :case_number, use: :scoped, scope: :casa_org- FriendlyId's
to_paramreturns the slug when present (friendly_id-5.7.0/lib/friendly_id/base.rb:262), socasa_case_path(casa_case)renders/casa_cases/cina-21-1234 - Nine call sites already resolve slugs via
CasaCase.friendly.find(casa_cases_controller.rb:219,court_dates_controller.rb:69,placements_controller.rb:59,emancipations_controller.rb:14,41,fund_requests_controller.rb:28,case_assignments_controller.rb:88,volunteers_controller.rb:91,case_court_report_context.rb:9)
So the work here is (a) adding the organization to the path, (b) closing the gaps where the slug scheme is currently unsafe or unfinished, and (c) doing it without breaking links people have already bookmarked or received by email.
Why add the org to the path
The case slug is only unique within an org, by design:
index_casa_cases_on_case_number_and_casa_org_id (case_number, casa_org_id) UNIQUE
index_casa_cases_on_slug (slug) <-- NOT unique
Two orgs can both have case CINA-21-1234. Today, /casa_cases/cina-21-1234 is therefore globally ambiguous, and resolving it correctly depends entirely on every single lookup remembering to scope to the current org. Some do:
# app/controllers/casa_cases_controller.rb:219 — correct
@casa_case = current_organization.casa_cases.friendly.find(params[:id])
Several don't:
| Unscoped lookup | Note |
|---|---|
app/controllers/fund_requests_controller.rb:28 |
Checks casa_org == current_user.casa_org after the find, so a duplicate slug in another org can bounce a legitimate user to root_path instead of their own case |
app/controllers/emancipations_controller.rb:14,41 |
Pundit authorize blocks a cross-org leak, but a duplicate slug yields a wrong 403 on a case the user does own |
app/controllers/case_assignments_controller.rb:88 |
case_assignment_parent — authorization of the resolved parent needs auditing |
app/controllers/volunteers_controller.rb:91 |
Used to pick a redirect target |
app/controllers/casa_cases_controller.rb:146 |
CasaCase.find_by_case_number(params[:case_number_cp]) in copy_court_orders — not org-scoped at all; please audit this one first, since it reads another record's court orders |
Putting the org in the path makes the ambiguity structurally impossible instead of a convention every future contributor has to remember.
Proposed URL shape
/org/:casa_org_id/casa_cases/:case_number
/org/prince-george-casa/casa_cases/cina-21-1234
/org/prince-george-casa/casa_cases/cina-21-1234/emancipation
Recommended over a composite single segment (e.g. /casa_cases/prince-george-casa--cina-21-1234) because it's conventional Rails routing, the nested children under resources :casa_cases (config/routes.rb:46-63) keep working unchanged, and the org is readable in the address bar.
Open decision — what identifies the org? CasaOrg has a slug column with a unique DB index, but it is not managed by FriendlyId and has sharp edges (app/models/casa_org.rb:11,88):
before_create :set_slug # create only -- renaming an org leaves the old slug
def set_slug
self.slug = name.parameterize # no model-level uniqueness validation
end
Two org names that parameterize identically would hit the DB unique index and 500 on org creation. Note the existing TODOs anticipating this work: app/models/casa_org.rb:137 and app/controllers/all_casa_admins/casa_admins_controller.rb:62. Options: use the numeric org id (ugly but safe), or harden the org slug (add a uniqueness validation, a collision suffix, and a decision about renames) before it becomes load-bearing. Worth a maintainer call before step 2 below.
Suggested safe rollout — four deploys
Each step is independently shippable and leaves the app working.
Step 1 — harden what exists (no user-visible change).
- Backfill missing case slugs. The original backfill was gutted and is now a no-op:
lib/tasks/deployment/20210925143244_populate_slugs_for_orgs_and_cases.rakeprints "task deleted because it uses the now-uncallable method set_slug". Any row that predates FriendlyId and was never re-saved still hasslug IS NULLand silently falls back to its integer id in URLs. Write a fresh After Party task and confirmCasaCase.where(slug: nil).count == 0(and the same forCasaOrg) before proceeding. - Make every case lookup org-scoped (the table above) and add request specs proving a slug that exists in two orgs resolves to the caller's own case.
- Decide and implement the org identifier hardening.
Step 2 — add the new routes alongside the old ones.
- Add the nested
/org/:casa_org_id/...routes; keep the existing flat routes. Nothing generates the new shape yet, so this is inert but testable. - Consider adding FriendlyId's
:historymodule (use: [:scoped, :history], plus thefriendly_id_slugstable) in this step. Todayshould_generate_new_friendly_id?(app/models/casa_case.rb:226) regenerates the slug whenevercase_numberchanges, so renaming a case immediately 404s every existing link to it — including links already sitting in supervisors' inboxes. History keeps old slugs resolving.
Step 3 — flip URL generation.
- Make the helpers emit the new shape, and fix the call sites that pass a raw integer id, since those break the moment the route stops accepting ids:
app/views/volunteers/index.html.erb:101—casa_case_path(volunteer.most_recent_attempt_case_id), where the id comes from a datatable SQL alias (app/datatables/volunteer_datatable.rb:71) rather than a loaded recordapp/notifications/youth_birthday_notifier.rb:26—casa_case_path(params[:casa_case].id)app/javascript/controllers/copy_court_orders_controller.js:32— string-builds/casa_cases/${this.casaCaseIdValue}/copy_court_ordersapp/javascript/src/case_emancipation.js:103— path regexcasa_cases\/[A-Za-z\-0-9]+\/emancipationneeds to match the nested shape
- Keep the flat routes as redirects to the new shape. Derive the org from the record, not from
current_user, so a cross-org guess still fails authorization rather than silently redirecting into someone else's org. - Scope check: ~62
casa_case_path/casa_case_url/casa_cases_pathcall sites inapp/, ~37 spec files referencing case paths.
Step 4 — retire the old shape.
- Watch logs for traffic on the legacy routes for at least one full release cycle. Old URLs live in already-delivered email (
app/views/supervisor_mailer/_active_volunteer_info.html.erb:3embedscasa_case_url(casa_case)), so they will keep arriving for a while. - Then remove the legacy routes, and — if we actually want "no more integer ids in URLs" to be enforced — explicitly reject numeric
:idparams, with a spec.friendly.findaccepts a numeric id indefinitely otherwise; that fallback is exactly what makes steps 1-3 safe, but it also means the migration is never "done" until we opt out of it deliberately.
Notes / gotchas for whoever picks this up
- Never build a case URL from
case_numberby hand. When two case numbers in one org parameterize to the same string, FriendlyId appends a uniquifying suffix, soslug != case_number.parameterize. Always go throughto_param/ the path helpers. - Changing
to_paramhas wide reach:redirect_to @casa_case,form_with model: @casa_case, andlink_to case.case_number, caseall shift at once. - There is already precedent for case-number-in-URL elsewhere:
app/controllers/case_court_reports_controller.rb:76looks upCasaCase.find_by(case_number: params[:id], casa_org: current_user.casa_org). Worth folding into the same convention so we don't end up with two case-identifying URL styles. - Multi-tenancy is the highest-severity bug class in this app (see
CLAUDE.md), and this change touches exactly that surface. Every step should carry request specs that assert a case from org A is unreachable while signed in to org B. spec/helpers/sidebar_helper_spec.rb:62hardcodes/casa_cases/some-case-slug/emancipation; path-shape assertions like this will need updating.
How to access the QA site
Login Details:
Link to QA site
Login Emails:
- volunteer1@example.com view site as a volunteer
- supervisor1@example.com view site as a supervisor
- casa_admin1@example.com view site as an admin
- all_casa_admin1@example.com view site as an all casa admin
- go to
/all_casa_admins/sign_in
- go to
password for all users: 12345678
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with config/routes.rb, app/models/casa_case.rb, and app/models/casa_org.rb, then audit the listed unscoped lookups beginning with copy_court_orders. Review the existing case-path call sites and request specs before choosing the organization identifier. Done requires org-scoped URLs, safe legacy redirects, backfilled slugs, and cross-org access tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, rails, ruby
- Domain
- backend, databases, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 32/100