diff --git a/AGENTS.md b/AGENTS.md index fa22fbe5ab..066e78c386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ This codebase (Rails 8.1) | `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` | | `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named | | `Featureable` | `featured`, `publicly_featured` scopes | -| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). Nothing should compare the whole column against a single code β€” the `filemaker_code_links` helper renders one archive link per code | +| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code β€” `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code | | `Mentioner` | ActionText @mention extraction and grouping | | `NameFilterable` | Name-based filtering | | `PayChargeExtensions` | Included into `Pay::Charge` (via `config/initializers/pay.rb`): turns a Stripe charge into an AWBW `Payment` + `Allocation`, resolving the membership invoice it pays | diff --git a/app/controllers/fm_archives_controller.rb b/app/controllers/fm_archives_controller.rb index 1d1c0369fe..77b4e7aa18 100644 --- a/app/controllers/fm_archives_controller.rb +++ b/app/controllers/fm_archives_controller.rb @@ -63,6 +63,8 @@ class FmArchivesController < ApplicationController { label: "Other", icon: "πŸ“Ž", tables: %w[notes] } ] + PORTAL_MODELS = [ Person, Organization ].freeze + def index authorize! :fm_archive @@ -92,10 +94,24 @@ def show end load_backlinks + load_portal_records end private + def load_portal_records + @portal_records = PORTAL_MODELS + .select { |model| model.filemaker_archives.include?(@record.class) } + .flat_map { |model| model.with_filemaker_code(@record.fm_id).to_a } + @portal_records |= organizations_via_projects if @record.is_a?(FmOrganization) + end + + # Most orgs carry a project code, reaching their FM organization through Project.OrgID. + def organizations_via_projects + project_ids = FmProject.where("data->>'$.OrgID' = ?", @record.fm_id).pluck(:fm_id) + Organization.with_any_filemaker_code(project_ids).to_a + end + def load_backlinks @backlinks = {} return unless @record.class.const_defined?(:HAS_MANY) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index e5fff83072..dbcdea1fea 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -1,18 +1,62 @@ module FilemakerHelper # One archive link per FileMaker code a record carries, since a record can answer to - # several (see FilemakerCoded). A code the archive has no row for renders as plain - # text. `label` prefixes each link, for a button or hint that has to read as an action. - def filemaker_code_links(record, archive:, table:, link_class: nil, label: nil) - codes = record.filemaker_codes - return if codes.empty? + # several (see FilemakerCoded). A `label` names the button, adding each code only when + # there are several to tell apart. + def filemaker_code_links(record, link_class: nil, label: nil, linked_only: false) + filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives, + link_class: link_class, label: label, linked_only: linked_only) + end + + # The hint under a FileMaker code input, linking each code to its archive record. + def filemaker_code_hint(record) + links = filemaker_code_links(record, link_class: "text-blue-600 underline hover:text-blue-800") + safe_join([ "In the archive:", links ], " ") if links + end + + # The FM projects among an org's codes. + def filemaker_project_links(organization, link_class: nil, label: nil, linked_only: false) + project_ids = FmProject.where(fm_id: organization.filemaker_codes).pluck(:fm_id) + filemaker_archive_links(project_ids, archives: [ FmProject ], link_class: link_class, label: label, linked_only: linked_only) + end + + # The FM organizations among an org's codes, plus the one each of its projects sits under. + def filemaker_organization_links(organization, link_class: nil, label: nil, linked_only: false) + codes = organization.filemaker_codes + parent_ids = FmProject.where(fm_id: codes).filter_map { |project| project.data["OrgID"].presence } + fm_organization_ids = (FmOrganization.where(fm_id: codes).pluck(:fm_id) + parent_ids).uniq + filemaker_archive_links(fm_organization_ids, archives: [ FmOrganization ], + link_class: link_class, label: label, linked_only: linked_only) + end + + # The way back from an archive row to the portal record carrying its code. + def filemaker_portal_link(record) + text, path = case record + when Person then [ record.full_name, person_path(record) ] + when Organization then [ record.name, organization_path(record) ] + end + + link_to text, path, class: button_classes(:primary_outline, size: :sm) + end + + # A code no archive table holds renders struck through, flagging a likely typo β€” + # or is dropped with `linked_only`, for nav buttons that must all lead somewhere. + def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, linked_only: false) + return if codes.empty? || !allowed_to?(:show?, :fm_archive) + + stored = archives.index_with { |archive| archive.where(fm_id: codes).pluck(:fm_id).to_set } + archive_by_code = codes.index_with { |code| archives.find { |candidate| stored[candidate].include?(code) } } + archive_by_code.compact! if linked_only + return if archive_by_code.empty? - stored = archive.where(fm_id: codes).pluck(:fm_id).to_set + show_code = label.nil? || archive_by_code.many? - safe_join(codes.map do |code| - text = [ label, code ].compact.join(" ") - next tag.span(text) unless stored.include?(code) + safe_join(archive_by_code.map do |code, archive| + text = show_code ? safe_join([ label, code ].compact, " ") : label + # line-through! outranks a link_class `underline`, which sets the same text-decoration-line. + next tag.span(text, class: [ link_class, "line-through! opacity-60" ], title: "No FileMaker archive record for #{code}") unless archive - link_to text, fm_archive_path(code, table: table), class: link_class, target: "_blank", rel: "noopener" + link_to text, fm_archive_path(code, table: archive.table_name.delete_prefix("fm_")), + class: link_class, title: "Open #{code} in the FileMaker archive", target: "_blank", rel: "noopener" end, " ") end end diff --git a/app/models/concerns/filemaker_coded.rb b/app/models/concerns/filemaker_coded.rb index 313f2e2a24..aa25669daf 100644 --- a/app/models/concerns/filemaker_coded.rb +++ b/app/models/concerns/filemaker_coded.rb @@ -1,18 +1,42 @@ # One record can answer to more than one FileMaker code β€” two records that each mapped # to a FileMaker row get merged, or the archive itself holds several rows for one -# person. The `filemaker_code` column holds them as a trimmed, comma-separated list; +# person. The `filemaker_code` column holds them as a sorted, comma-separated list; # this is the seam for reading and combining them, so nothing compares the whole -# column against a single code. +# column against a single code. Codes never hold whitespace, so any (tabs, line +# breaks, pasted non-breaking spaces) is dropped. module FilemakerCoded extend ActiveSupport::Concern + included do + class_attribute :filemaker_archives, instance_writer: false, default: [] + + normalizes :filemaker_code, with: ->(value) { join_filemaker_codes(value) }, apply_to_nil: true + end + def filemaker_codes self.class.split_filemaker_codes(filemaker_code) end class_methods do + # The Fm* archive tables this model's codes point into, checked in order. + def filemaker_archived_in(*archives) + self.filemaker_archives = archives + end + + def with_filemaker_code(code) + where("FIND_IN_SET(?, REGEXP_REPLACE(filemaker_code, '[[:space:]]', ''))", strip_filemaker_code(code)) + end + + def with_any_filemaker_code(codes) + codes.map { |code| with_filemaker_code(code) }.reduce(:or) || none + end + def split_filemaker_codes(value) - value.to_s.split(",").map(&:strip).reject(&:blank?).uniq + value.to_s.split(",").map { |code| strip_filemaker_code(code) }.reject(&:empty?).uniq + end + + def strip_filemaker_code(code) + code.to_s.gsub(/[[:space:]]/, "") end # A single normalized column value from any mix of code strings/lists. diff --git a/app/models/organization.rb b/app/models/organization.rb index 91af0f0cd4..a507aa8dae 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -2,6 +2,7 @@ class Organization < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable include Communicable include FilemakerCoded + filemaker_archived_in FmProject, FmOrganization belongs_to :organization_status belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true diff --git a/app/models/person.rb b/app/models/person.rb index 5238306898..b68727f138 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -2,6 +2,7 @@ class Person < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable include Communicable include FilemakerCoded + filemaker_archived_in FmRolodex pay_customer default_payment_processor: :stripe diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb index 57b0911b36..e45d91251d 100644 --- a/app/views/fm_archives/show.html.erb +++ b/app/views/fm_archives/show.html.erb @@ -9,6 +9,17 @@ <%= link_to "Back to list", fm_archives_path(table: @record.class.table_name.sub("fm_", "")), class: button_classes(:primary_outline) %> + <% if @portal_records.any? %> +
+

In the portal

+
+ <% @portal_records.each do |record| %> + <%= filemaker_portal_link(record) %> + <% end %> +
+
+ <% end %> +
<% @record.data.each do |key, value| %> diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 7d9c15319b..572f087472 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -179,16 +179,11 @@ <% if allowed_to?(:manage?, Organization) %>
<%= f.input :filemaker_code, - label: "FileMaker Project ID", + label: "FileMaker project or organization ID", + hint: filemaker_code_hint(f.object), input_html: { class: "rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> - <%- if params[:admin] == "true" %> -

- <%= filemaker_code_links(f.object, archive: FmProject, table: "projects", label: "View archive", - link_class: "text-blue-600 hover:text-blue-800 underline") %> -

- <% end %> <% high_profile_gem = tag.i(class: "fa-solid fa-gem text-purple-600", aria: { hidden: true }) %> <%= f.input :high_profile, label: "High profile", diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index 31a36d4c4b..7d73eac082 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -9,10 +9,10 @@ <% end %> <% end %> <% actions = capture do %> - <% if params[:admin] == "true" %> - <%= filemaker_code_links(@organization, archive: FmProject, table: "projects", label: "FM project", - link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> - <% end %> + <%= filemaker_project_links(@organization, label: "FM project", linked_only: true, + link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> + <%= filemaker_organization_links(@organization, label: "FM organization", linked_only: true, + link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "Organizations", organizations_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "Profile", organization_path(@organization), class: "text-sm #{eyebrow_link_class} px-2 py-1" %> diff --git a/app/views/people/_admin_details.html.erb b/app/views/people/_admin_details.html.erb index 959aebb3c8..8a4b71a944 100644 --- a/app/views/people/_admin_details.html.erb +++ b/app/views/people/_admin_details.html.erb @@ -98,8 +98,7 @@
FileMaker code
- <%= filemaker_code_links(person, archive: FmRolodex, table: "rolodexes", - link_class: "text-blue-600 underline hover:text-blue-800") || "β€”" %> + <%= filemaker_code_links(person, link_class: "text-blue-600 underline hover:text-blue-800") || "β€”" %>
Racial/ethnic identity
<%= person.racial_ethnic_identity.presence || "β€”" %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index a2f373c923..0b8e97086d 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -467,14 +467,9 @@
<%= f.input :filemaker_code, label: "FileMaker code", + hint: filemaker_code_hint(f.object), input_html: { class: "w-full" }, wrapper_html: { class: "w-full" } %> - <%- if params[:admin] == "true" %> -

- <%= filemaker_code_links(f.object, archive: FmRolodex, table: "rolodexes", label: "View archive", - link_class: "text-blue-600 hover:text-blue-800 underline") %> -

- <% end %>
diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 0120c27367..0715d3a882 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -32,13 +32,11 @@ <% if @person.user %> <%= link_to "User account", user_path(@person.user), class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> <% end %> + <%= filemaker_code_links(@person, label: "Rolodex", linked_only: true, + link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> <% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %> <%= link_to "Activity", admin_activities_events_path(person_id: @person.id, time_period: "all_time", audience: %w[visitors users staff], hide_account: "1", hide_interactions: "1"), class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %> <% end %> - <% if params[:admin] == "true" %> - <%= filemaker_code_links(@person, archive: FmRolodex, table: "rolodexes", label: "Rolodex", - link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> - <% end %> <%= link_to "Change password", change_password_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> <%= link_to "People", people_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %> diff --git a/config/features.yml b/config/features.yml index bcfbf12471..86b08a0409 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4221,3 +4221,18 @@ The FileMaker archive now sorts its 44 tables into labeled groups β€” people, organizations, events, payments, funding and more β€” so the table you want is easier to find. + +- name: "Jump between people, orgs, and the FileMaker archive" + area: people + display_status: admin_facing + released_on: 2026-10-12 + pr_number: 2648 + summary: >- + Each FileMaker code on person and organization edit pages links to its + archive record, and each archive record links back to the portal person or + organization that carries its code. + pro_tips: + - "A struck-through code means the archive has no record for that code β€” check it for a typo." + - "Separate several codes with commas. Spaces, tabs, and line breaks are ignored, and the list saves sorted." + - "An organization's edit page also links the FileMaker organization its project belongs to." + - "An organization's code can be a FileMaker project ID (like P2775) or organization ID (like ORG07206). Type the whole code, letters included." diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 934aeebddc..55643d059c 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -24,22 +24,155 @@ drive the archive pages' links. | App column | Archive table | Archive key | Read by | |---|---|---|---| | `people.filemaker_code` | `fm_rolodexes` | `ID`, e.g. `00006` | `filemaker_code_links` on person edit + admin details | -| `organizations.filemaker_code` | `fm_projects` | `ProjectID`, e.g. `P2775` | `filemaker_code_links` on org edit + form | -| `affiliations.filemaker_code` | `fm_personnels` | `PrsnlRecID`, e.g. `PS9999` | dev seed (no UI link yet) | +| `organizations.filemaker_code` | `fm_projects`, then `fm_organizations` | `ProjectID`, e.g. `P2775`; `OrgID`, e.g. `ORG07206` | `filemaker_code_links` on org form; `filemaker_project_links` / `filemaker_organization_links` on org edit | +| `affiliations.filemaker_code` | `fm_projects`, then `fm_personnels` | `ProjectID`, e.g. `P2775`; rarely `PrsnlRecID`, e.g. `PS9999` | no UI link yet | | `payments.filemaker_code` | `fm_payments` | `RecordID`, e.g. `D29186` | dev seed (no UI link yet) | | `workshops.filemaker_code` | `fm_workshops` | `WorkshopID` | dev seed (no UI link yet) | -| `resources.filemaker_code` | none | β€” | unmapped | +| `resources.filemaker_code` | `fm_quotations` | `RecordID`, e.g. `Q10215` | no UI link yet | -- **Organization β†’ Project, not β†’ Organization.** An app `Organization` is one art - program site, which is a FileMaker *Project*. FileMaker's own Organization row is - the umbrella above it, and maps to the parent in the nested-org tree (ADR-0004). -- **Affiliation β†’ Personnel.** Personnel is a person on a project with a role and - agreement status. With orgs = projects, that's an affiliation. -- **Resource has no counterpart.** No archive table holds resources. Form Letters - are mail-merge templates, not resources, and nothing in the app maps to them. The - seed leaves `resources.filemaker_code` blank. +- **Organization β†’ Project, or β†’ Organization.** An app `Organization` is usually one + art program site, which is a FileMaker *Project*. FileMaker's own Organization row is + the umbrella above it, and maps to the parent in the nested-org tree (ADR-0004); an + app org can carry that `ORG…` code directly too. Links run both ways through + `Project.OrgID`: the org edit page links the FM organization each of its projects + sits under, and an FM organization's archive page lists the orgs carrying its code + or one of its projects' codes. +- **Codes match in full, never by number alone.** `P00023` and `ORG00023` are different + records, and a bare `00023` matches neither. The prefix is part of the id, and + prefixes aren't unique to one table (see below), so nothing strips or routes by it. +- **Affiliation β†’ Project.** Production affiliations carry their org's project id, + not a Personnel id. Of 1,531 `P` + 4-digit codes, all 1,531 are real projects and + 1,518 equal the affiliation's own org code. Reading them as Personnel ids missing the + `S` matched the affiliation's person once and its org never; as participant ids, + never its org. One affiliation carries a real `PS` Personnel id, so Personnel stays + as the second table. The 13 that differ from the org's code are worth a look: the + affiliation may sit on the wrong org, or the org may be missing that code. +- **Resource β†’ Quotation.** In production, 116 of 128 resource codes are quotation ids + (`Q…`), 115 of them on Leader spotlight resources and one on a Story; 115 of the 116 + exist in `fm_quotations` (quotes and testimonials, with a `WorkShopLeader` field). + Form Letters are mail-merge templates, not resources. The dev seed still leaves + `resources.filemaker_code` blank. + +## Archive id patterns + +What each archive table's `fm_id` looks like in production, profiled 2026-10-12 with a +digits-as-`9` shape query over every archive table. Every table came back with a +single prefix and fixed-width digits unless noted. Re-run it and update this grid +when a new export lands. + +| Archive table | FileMaker key | Id pattern | Range in prod | Rows | Portal column | +|---|---|---|---|---|---| +| `fm_activities` | `ActivityLogID` | 5 digits | `00011`–`30061` | 29,185 | | +| `fm_addresses` | `AddrsID` | 1–5 digits, unpadded | `1`–`30808` | 28,556 | | +| `fm_allocations` | `AllocRecID` | 4 digits | `0001`–`3508` | 3,098 | | +| `fm_ancillary_data` | `RecordID` | 1–3 digits, unpadded | `1`–`559` | 556 | | +| `fm_art_purchases` | `PurchaseID` | 4 digits | `0001`–`0226` | 226 | | +| `fm_art_registries` | `ItemID` | `A` + 4 digits | `A0001`–`A4405` | 3,902 | | +| `fm_campaigns` | `CampaignID` | `CMP` + 3 digits | `CMP002`–`CMP011` | 10 | | +| `fm_committee_meetings` | `CtMtgID` | `CMT` + 4 digits | `CMT0001` | 1 | | +| `fm_committee_members` | `CMRecID` | `CM` + 4 digits | `CM0001`–`CM0741` | 689 | | +| `fm_committees` | `CommitteeID` | `CT` + 3 digits | `CT001`–`CT060` | 28 | | +| `fm_contacts` | `ContactID` | 5 digits | `00030`–`35548` | 248 | | +| `fm_donor_developers` | `DonorDevRecID` | `SLR` + 4 digits | `SLR0001`–`SLR0172` | 164 | | +| `fm_email_addresses` | `EmailAddrsID` | 1–2 digits, unpadded | `1`–`29` | 29 | | +| `fm_events` | `EventID` | `E` + 4 digits | `E0002`–`E4048` | 3,478 | | +| `fm_exhibited_items` | `RecordID` | `X` + 5 digits | `X00002`–`X03154` | 2,693 | | +| `fm_expenditures` | `ExpendRecID` | `X` + 4 digits | `X0001`–`X5510` | 5,314 | | +| `fm_form_fields` | `FieldNo` | 1–3 digits, optional `.` + 1–2 digits | `1.3`–`125.4` | 318 | | +| `fm_form_letters` | `DocID` | `D` + 3 digits | `D064`–`D658` | 272 | | +| `fm_form_submissions` | `RecordID` | `FSB` + 5 digits | `FSB00001`–`FSB03265` | 3,265 | | +| `fm_forms_lists` | `FormID` | 2–3 digits, unpadded | `48`–`307` | 9 | | +| `fm_fundings` | `RecordID` | `F` + 4 digits | `F0004`–`F1581` | 1,391 | | +| `fm_group_members` | `RecordID` | `GM` + 5 digits | `GM00001`–`GM00017` | 16 | | +| `fm_groups` | `GroupID` | `GP` + 5 digits | `GP00004`–`GP00273` | 245 | | +| `fm_invoices` | `RecordID` | `INV` + 5 digits | `INV00003`–`INV02247` | 2,064 | | +| `fm_line_items` | `ItemID` | `LIT` + 5 digits | `LIT01001`–`LIT01103` | 103 | | +| `fm_match_donations` | `RecordID` | `MDN` + 4 digits | `MDN0001`–`MDN3454` | 3,250 | | +| `fm_notes` | `NoteID` | `N` + 5 digits | `N00007`–`N00643` | 616 | | +| `fm_organizations` | `OrgID` | `ORG` + 5 digits | `ORG00001`–`ORG07256` | 7,240 | `organizations` | +| `fm_participants` | `PRecID` | `P` + 4–5 digits | `P0001`–`P79284` | 68,850 | | +| `fm_payments` | `RecordID` | `D` + 5 digits | `D00001`–`D29263` | 27,220 | `payments` | +| `fm_people_to_organizations` | `RecordID` | 1–4 digits, unpadded | `1`–`1128` | 1,128 | | +| `fm_personnels` | `PrsnlRecID` | `PS` + 4–5 digits | `PS0028`–`PS10667` | 9,037 | `affiliations` | +| `fm_phone_numbers` | `PhoneID` | 2–5 digits, unpadded | `22`–`35946` | 34,040 | | +| `fm_postal_codes` | `Zipcode` | 5-digit ZIP | `00501`–`99950` | 41,685 | | +| `fm_program_sponsorships` | `RecordID` | `PSP` + 4 digits | `PSP0035`–`PSP0901` | 608 | | +| `fm_projects` | `ProjectID` | `P` + 4 digits | `P0001`–`P2806` | 2,007 | `organizations` | +| `fm_quotations` | `RecordID` | `Q` + 4–5 digits | `Q0880`–`Q65300` | 62,611 | | +| `fm_reportings` | `ReportID` | 5 digits | `00001`–`00791` | 727 | | +| `fm_rolodexes` | `ID` | 5 digits, zero-padded | `00006`–`43871` | 39,795 | `people` | +| `fm_services` | `RecordID` | 5 digits | `00001`–`73063` | 69,590 | | +| `fm_solicitations` | `SolicitationID` | `SLN` + 5 digits | `SLN00001`–`SLN12043` | 11,815 | | +| `fm_volunteers` | `VolID` | 5 digits | `00001`–`37788` | 2,845 | | +| `fm_workshop_logs` | `RecordID` | 5 digits | `45920`–`56833` | 10,859 | | +| `fm_workshops` | `WorkshopID` | `W` + 4 digits | `W0002`–`W0830` | 713 | `workshops` | + +### Where ids collide across tables + +The same string can be a valid id in more than one table, so a code is only ever +looked up, in full, in the tables its portal column declares β€” never routed by prefix +or shape. + +- **`P` + 4 digits is both a project and a participant.** Projects run `P0001`–`P2806`; + participants run `P0001`–`P79284`, so every project id is also a participant id. +- **`X`:** exhibited items are `X` + 5 digits, expenditures `X` + 4 digits. +- **`D`:** form letters are `D` + 3 digits, payments `D` + 5 digits. +- **Nested prefixes:** `P` / `PS` / `PSP`, `CM` / `CMP` / `CMT`, and `SLR` / `SLN`. +- **Bare 5-digit numbers** are Rolodex ids and also activities, contacts, services, + volunteers, reportings, workshop logs, and ZIP codes, with overlapping ranges. +- **Unpadded numbers** (addresses, phones, ancillary data, people-to-organizations, + email addresses, forms lists) overlap each other from `1` up. + +## What portal records store + +Shapes of the codes on portal records in production, profiled 2026-10-12 (comma lists +split into single codes). Compare against the grid above. + +| Portal column | Codes | Matches its mapping | Outliers | +|---|---|---|---| +| `organizations` | 1,987 | 1,965 `P` + 4 digits (projects) | 22: unpadded `P622`; no prefix `627`, `628`, `2781`, `9530`; letter O for zero `PO296`; extra digit `P03484`; `PP256`; placeholder `F0000` Γ—2; bare 5-digit numbers Γ—9 (Rolodex-shaped); 2 unreadable values | +| `people` | 1 | 1 Rolodex-shaped (`39853`) | β€” the column is effectively unused | +| `affiliations` | 1,535 | 1,531 `P` + 4 digits (projects; 1,518 equal the org's code), 1 `PS` (personnel) | `ORG05589`, `PP208`, `09856` | +| `payments` | 0 | β€” | β€” | +| `workshops` | 850 | 849 `W` + 4 digits | `XVI/4` | +| `resources` | 128 | 115 `Q` + 4–5 digits found in quotations (114 Leader spotlight, 1 Story) | 1 `Q` id not in the archive; 10 bare 2–3 digit numbers, `v`, and a URL, all on Leader spotlights | + +Outliers get fixed by hand, not backfilled. Those codes already show struck through +in the code field's hint. + +### Records to fix by hand + +Ids only, by issue. Nothing here names a record or repeats its code; open the record by +id to see the code. Look the right id up in the archive, then fix the record's +FileMaker code field. + +| Issue | Fix | +|---|---| +| Well-formed code with no archive record | Likely a digit typo. Find the record in the archive by name and replace the code, or clear it if FileMaker has no such record. | +| Number without its prefix, or the wrong kind of id | Add the missing prefix and padding (e.g. `P` + 4 digits for a project), or, if it's another table's id (a 5-digit Rolodex id on an org), move it to the right record and clear it here. | +| Malformed code | Fix the letters (a letter `O` for a zero, a doubled prefix), drop extra digits, or clear placeholders and pasted text. An `ORG…` code on an affiliation belongs on its org instead. | +| Unreadable value | The stored value has characters that can't be split into codes. Retype the code. | +| Project code differs from its org's code (affiliations) | Either the affiliation is on the wrong org (move it), or the org is missing that project code (add it to the org). | + +Production, 2026-10-12: 84 records (41 organizations, 16 affiliations, 14 workshops, +13 resources, no people). A record listed under two issues has two bad codes. + +| Portal table | Issue | Count | Record ids | +|---|---|---|---| +| `organizations` | Well-formed code with no archive record | 19 | 66, 247, 270, 283, 297, 365, 425, 437, 443, 499, 665, 668, 670, 814, 973, 1046, 1091, 1107, 1398 | +| `organizations` | Number without its prefix, or the wrong kind of id | 14 | 233, 364, 388, 439, 593, 614, 640, 641, 822, 838, 865, 877, 978, 2043 | +| `organizations` | Malformed code | 8 | 108, 136, 241, 941, 1088, 1089, 1550, 1891 | +| `affiliations` | Number without its prefix, or the wrong kind of id | 1 | 4424 | +| `affiliations` | Malformed code | 2 | 5236, 6385 | +| `affiliations` | Project code differs from its org's code | 13 | 2656, 3649, 3730, 5042, 5043, 5164, 5461, 5857, 5920, 6178, 6234, 6284, 6387 | +| `workshops` | Well-formed code with no archive record | 13 | 215, 294, 357, 501, 708, 756, 797, 865, 926, 954, 982, 1010, 6302 | +| `workshops` | Malformed code | 1 | 521 | +| `resources` | Well-formed code with no archive record | 1 | 624 | +| `resources` | Number without its prefix, or the wrong kind of id | 10 | 572, 573, 574, 575, 588, 592, 593, 594, 595, 596 | +| `resources` | Malformed code | 2 | 550, 580 | ## Consequences -A UI link for affiliations, payments, or workshops passes the archive table above to -`filemaker_code_links` (e.g. `archive: FmPersonnel, table: "personnels"`). +A UI link for affiliations, payments, workshops, or resources includes `FilemakerCoded` on the +model and declares the archive table above (e.g. `filemaker_archived_in FmPersonnel`); +`filemaker_code_links` reads it from there. diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb new file mode 100644 index 0000000000..4485c1a52b --- /dev/null +++ b/spec/helpers/filemaker_helper_spec.rb @@ -0,0 +1,151 @@ +require "rails_helper" + +RSpec.describe FilemakerHelper, type: :helper do + let(:archive_viewer) { true } + + before do + allow(helper).to receive(:allowed_to?).with(:show?, :fm_archive).and_return(archive_viewer) + end + + describe "#filemaker_code_links" do + let(:person) { build(:person, filemaker_code: "00006, 00012") } + + before { FmRolodex.create!(fm_id: "00006") } + + it "links each code the archive holds to its archive table" do + html = helper.filemaker_code_links(person, label: "Rolodex") + + expect(html).to include(%(href="#{fm_archive_path("00006", table: "rolodexes")}")) + expect(html).to include("Rolodex 00006") + end + + it "shows just the label when the record carries a single code" do + html = helper.filemaker_code_links(build(:person, filemaker_code: "00006"), label: "Rolodex") + + expect(html).to include(">Rolodex") + expect(html).not_to include("Rolodex 00006") + end + + it "marks a code the archive has no row for instead of linking it" do + html = helper.filemaker_code_links(person) + + expect(html).not_to include(fm_archive_path("00012", table: "rolodexes")) + expect(html).to include(%(title="No FileMaker archive record for 00012")) + end + + it "returns nothing for a record without codes" do + expect(helper.filemaker_code_links(build(:person, filemaker_code: nil))).to be_nil + end + + context "when only linked codes are wanted" do + it "drops a code the archive has no row for" do + html = helper.filemaker_code_links(person, label: "Rolodex", linked_only: true) + + expect(html).not_to include("00012") + expect(html).not_to include("No FileMaker archive record") + end + + it "names the one remaining link by its label alone" do + html = helper.filemaker_code_links(person, label: "Rolodex", linked_only: true) + + expect(html).to include(">Rolodex") + expect(html).not_to include("Rolodex 00006") + end + + it "returns nothing when no code has an archive row" do + html = helper.filemaker_code_links(build(:person, filemaker_code: "00012"), label: "Rolodex", linked_only: true) + + expect(html).to be_nil + end + end + + context "when the viewer can't open the archive" do + let(:archive_viewer) { false } + + it "renders nothing" do + expect(helper.filemaker_code_links(person)).to be_nil + end + end + end + + describe "#filemaker_code_hint" do + before { FmRolodex.create!(fm_id: "00006") } + + it "lists each code as an archive link" do + html = helper.filemaker_code_hint(build(:person, filemaker_code: "00006")) + + expect(html).to include("In the archive:") + expect(html).to include(%(href="#{fm_archive_path("00006", table: "rolodexes")}")) + expect(html).to include("00006") + end + + it "returns nothing for a record without codes" do + expect(helper.filemaker_code_hint(build(:person, filemaker_code: nil))).to be_nil + end + end + + describe "#filemaker_organization_links" do + let(:organization) { build(:organization, filemaker_code: "P2775") } + + before do + FmProject.create!(fm_id: "P2775", data: { "OrgID" => "ORG07206" }) + FmOrganization.create!(fm_id: "ORG07206") + end + + it "links the FM organization each of the org's projects belongs to" do + html = helper.filemaker_organization_links(organization, label: "FM organization") + + expect(html).to include(fm_archive_path("ORG07206", table: "organizations")) + expect(html).to include(">FM organization") + end + + it "drops an FM organization the archive has no row for when only linked ones are wanted" do + FmProject.create!(fm_id: "P9000", data: { "OrgID" => "ORG99999" }) + + html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P9000"), label: "FM organization", linked_only: true) + + expect(html).to be_nil + end + + it "links an FM organization the org carries directly, alongside its projects' FM organizations" do + FmOrganization.create!(fm_id: "ORG00023") + + html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P2775, ORG00023"), label: "FM organization") + + expect(html).to include(fm_archive_path("ORG00023", table: "organizations")) + expect(html).to include(fm_archive_path("ORG07206", table: "organizations")) + end + + it "links the FM organization once when the org carries it and one of its projects sits under it" do + html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P2775, ORG07206"), label: "FM organization") + + expect(html.scan(fm_archive_path("ORG07206", table: "organizations")).size).to eq(1) + end + end + + describe "#filemaker_project_links" do + it "links only the org's FM project codes" do + FmProject.create!(fm_id: "P2775") + FmOrganization.create!(fm_id: "ORG00023") + + html = helper.filemaker_project_links(build(:organization, filemaker_code: "P2775, ORG00023"), label: "FM project", linked_only: true) + + expect(html).to include(fm_archive_path("P2775", table: "projects")) + expect(html).not_to include("ORG00023") + expect(html).to include(">FM project") + end + end + + describe "organization codes" do + it "match the full code, so a bare number reaches neither a project nor an FM organization" do + FmProject.create!(fm_id: "P00023") + FmOrganization.create!(fm_id: "ORG00023") + + html = helper.filemaker_code_links(build(:organization, filemaker_code: "00023, P00023, ORG00023")) + + expect(html).to include(fm_archive_path("P00023", table: "projects")) + expect(html).to include(fm_archive_path("ORG00023", table: "organizations")) + expect(html).to include(%(title="No FileMaker archive record for 00023")) + end + end +end diff --git a/spec/models/concerns/filemaker_coded_spec.rb b/spec/models/concerns/filemaker_coded_spec.rb new file mode 100644 index 0000000000..6098393b91 --- /dev/null +++ b/spec/models/concerns/filemaker_coded_spec.rb @@ -0,0 +1,70 @@ +require "rails_helper" + +RSpec.describe FilemakerCoded do + describe ".with_filemaker_code" do + it "matches a code anywhere in the list, however it's spaced" do + single = create(:person, filemaker_code: "00006") + listed = create(:person, filemaker_code: "00012,00006") + spaced = create(:person, filemaker_code: "00012 , 00006") + create(:person, filemaker_code: "000066") + create(:person, filemaker_code: nil) + + expect(Person.with_filemaker_code(" 00006 ")).to contain_exactly(single, listed, spaced) + end + + it "ignores tabs, line breaks, and non-breaking spaces stored in the list" do + messy = create(:person) + messy.update_columns(filemaker_code: "00012,\t00006\u00A0\n") + + expect(Person.with_filemaker_code("00006")).to contain_exactly(messy) + end + end + + describe ".with_any_filemaker_code" do + it "matches records carrying any of the codes" do + first = create(:person, filemaker_code: "00006") + second = create(:person, filemaker_code: "00012, 00099") + create(:person, filemaker_code: "00013") + + expect(Person.with_any_filemaker_code(%w[00006 00012])).to contain_exactly(first, second) + end + + it "matches nothing for no codes" do + create(:person, filemaker_code: "00006") + + expect(Person.with_any_filemaker_code([])).to be_empty + end + end + + describe "#filemaker_codes" do + it "splits on commas only, dropping every kind of whitespace" do + person = build(:person) + person.filemaker_code = " 00006\t,\n00012\u00A0, ,00006" + + expect(person.filemaker_codes).to eq(%w[00006 00012]) + end + + it "keeps a list joined by another separator as one code" do + expect(build(:person, filemaker_code: "00006; 00012").filemaker_codes).to eq([ "00006;00012" ]) + end + end + + describe "saving" do + it "stores the codes as a sorted, comma-space list" do + person = create(:person, filemaker_code: "00012 ,\t00006,00012\n") + + expect(person.reload.filemaker_code).to eq("00006, 00012") + end + + it "stores nil for a blank list" do + expect(create(:person, filemaker_code: " , \t").reload.filemaker_code).to be_nil + end + end + + describe ".filemaker_archives" do + it "names the archive tables each coded model points into" do + expect(Person.filemaker_archives).to eq([ FmRolodex ]) + expect(Organization.filemaker_archives).to eq([ FmProject, FmOrganization ]) + end + end +end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index cc12f19b66..e4e16165e5 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -3,22 +3,93 @@ RSpec.describe "FM archives", type: :request do let(:admin) { create(:user, :admin) } - before { sign_in admin } - describe "GET /fm_archives/:id" do - let!(:rolodex) { FmRolodex.create!(fm_id: "ROL1", data: { "ID" => "ROL1" }) } - let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1", "FunderID" => "ROL1" }) } + before { sign_in admin } + + context "with related records" do + let!(:rolodex) { FmRolodex.create!(fm_id: "ROL1", data: { "ID" => "ROL1" }) } + let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1", "FunderID" => "ROL1" }) } + + it "lists a rolodex record's funding under related records" do + get fm_archive_path(rolodex.fm_id, table: "rolodexes") + + expect(response.body).to include("Funding (1)", "table=fundings") + end + end + + it "links back to the portal people carrying the archive code" do + FmRolodex.create!(fm_id: "00006") + person = create(:person, first_name: "Archie", last_name: "Coded", filemaker_code: "00012, 00006") + create(:person, filemaker_code: "000066") + + get fm_archive_path("00006", table: "rolodexes") + + expect(response).to have_http_status(:ok) + expect(response.body).to include(person_path(person)) + expect(response.body).to include("Archie Coded") + expect(response.body.scan(%r{href="/people/\d+"}).size).to eq(1) + end + + it "links an archived project back to its portal organization" do + FmProject.create!(fm_id: "P2775") + organization = create(:organization, filemaker_code: "P2775") + + get fm_archive_path("P2775", table: "projects") + + expect(response.body).to include(organization_path(organization)) + end - it "lists a rolodex record's funding under related records" do - get fm_archive_path(rolodex.fm_id, table: "rolodexes") + it "links an archived FM organization back to the portal organization carrying its code" do + FmOrganization.create!(fm_id: "ORG00023") + organization = create(:organization, filemaker_code: "ORG00023") + create(:organization, filemaker_code: "P00023") - expect(response.body).to include("Funding (1)", "table=fundings") + get fm_archive_path("ORG00023", table: "organizations") + + expect(response.body).to include(organization_path(organization)) + expect(response.body.scan(%r{href="/organizations/\d+"}).size).to eq(1) + end + + it "links an archived FM organization back to the portal organizations its projects belong to" do + FmOrganization.create!(fm_id: "ORG07206") + FmProject.create!(fm_id: "P2775", data: { "OrgID" => "ORG07206" }) + FmProject.create!(fm_id: "P2776", data: { "OrgID" => "ORG07206" }) + FmProject.create!(fm_id: "P9000", data: { "OrgID" => "ORG09999" }) + via_project = create(:organization, filemaker_code: "P2775") + via_both = create(:organization, filemaker_code: "P2776, ORG07206") + create(:organization, filemaker_code: "P9000") + + get fm_archive_path("ORG07206", table: "organizations") + + expect(response.body).to include(organization_path(via_project), organization_path(via_both)) + expect(response.body.scan(%r{href="/organizations/\d+"}).size).to eq(2) + end + + it "omits the portal section when nothing carries the code" do + FmRolodex.create!(fm_id: "00006") + + get fm_archive_path("00006", table: "rolodexes") + + expect(response.body).not_to include("In the portal") + end + end + + describe "GET /fm_archives/:id as a non-admin" do + it "is not allowed" do + FmRolodex.create!(fm_id: "00006") + sign_in create(:user) + + get fm_archive_path("00006", table: "rolodexes") + + expect(response).to redirect_to(root_path) end end describe "GET /fm_archives" do let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1" }) } + before { sign_in admin } + it "lists funding records at the table name a funding record's back link uses" do get fm_archives_path(table: FmFunding.table_name.delete_prefix("fm_"))