Skip to content

Include the org in CASA case URLs and finish the switch away from database ids #7106

Description

@compwron

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:26friendly_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:101casa_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:26casa_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions