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_param returns the slug when present (friendly_id-5.7.0/lib/friendly_id/base.rb:262), so casa_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.rake prints "task deleted because it uses the now-uncallable method set_slug". Any row that predates FriendlyId and was never re-saved still has slug IS NULL and silently falls back to its integer id in URLs. Write a fresh After Party task and confirm CasaCase.where(slug: nil).count == 0 (and the same for CasaOrg) 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
:history module (use: [:scoped, :history], plus the friendly_id_slugs table) in this step. Today should_generate_new_friendly_id? (app/models/casa_case.rb:226) regenerates the slug whenever case_number changes, 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 record
app/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_orders
app/javascript/src/case_emancipation.js:103 — path regex casa_cases\/[A-Za-z\-0-9]+\/emancipation needs 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_path call sites in app/, ~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:3 embeds casa_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
:id params, with a spec. friendly.find accepts 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_number by hand. When two case numbers in one org parameterize to the same string, FriendlyId appends a uniquifying suffix, so slug != case_number.parameterize. Always go through to_param / the path helpers.
- Changing
to_param has wide reach: redirect_to @casa_case, form_with model: @casa_case, and link_to case.case_number, case all shift at once.
- There is already precedent for case-number-in-URL elsewhere:
app/controllers/case_court_reports_controller.rb:76 looks up CasaCase.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:62 hardcodes /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:
password for all users: 12345678
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.
CasaCasealready 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_orgto_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-1234CasaCase.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:
Two orgs can both have case
CINA-21-1234. Today,/casa_cases/cina-21-1234is therefore globally ambiguous, and resolving it correctly depends entirely on every single lookup remembering to scope to the current org. Some do:Several don't:
app/controllers/fund_requests_controller.rb:28casa_org == current_user.casa_orgafter the find, so a duplicate slug in another org can bounce a legitimate user toroot_pathinstead of their own caseapp/controllers/emancipations_controller.rb:14,41authorizeblocks a cross-org leak, but a duplicate slug yields a wrong 403 on a case the user does ownapp/controllers/case_assignments_controller.rb:88case_assignment_parent— authorization of the resolved parent needs auditingapp/controllers/volunteers_controller.rb:91app/controllers/casa_cases_controller.rb:146CasaCase.find_by_case_number(params[:case_number_cp])incopy_court_orders— not org-scoped at all; please audit this one first, since it reads another record's court ordersPutting the org in the path makes the ambiguity structurally impossible instead of a convention every future contributor has to remember.
Proposed URL shape
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 underresources :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?
CasaOrghas aslugcolumn with a unique DB index, but it is not managed by FriendlyId and has sharp edges (app/models/casa_org.rb:11,88):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:137andapp/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).
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.Step 2 — add the new routes alongside the old ones.
/org/:casa_org_id/...routes; keep the existing flat routes. Nothing generates the new shape yet, so this is inert but testable.: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.
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 shapecurrent_user, so a cross-org guess still fails authorization rather than silently redirecting into someone else's org.casa_case_path/casa_case_url/casa_cases_pathcall sites inapp/, ~37 spec files referencing case paths.Step 4 — retire the old shape.
app/views/supervisor_mailer/_active_volunteer_info.html.erb:3embedscasa_case_url(casa_case)), so they will keep arriving for a while.: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
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.to_paramhas wide reach:redirect_to @casa_case,form_with model: @casa_case, andlink_to case.case_number, caseall shift at once.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.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:
/all_casa_admins/sign_inpassword for all users: 12345678