From 46798300e14459aa6dd48e4a890295ae1c54c24a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 14:55:53 -0400 Subject: [PATCH 01/17] Start: FileMaker archive links from people, orgs, and affiliations Co-Authored-By: Claude Opus 5.5 (1M context) From eb0c69d517341945baa87fca031809abe5159a12 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 15:02:15 -0400 Subject: [PATCH 02/17] Put FileMaker archive buttons beside person and org codes, and link back The archive buttons in the edit-page nav checked only the ?admin=true param, so an owner editing their own profile with it set saw their FileMaker code and a link that 403s. The helper now checks the archive policy, so admin mode no longer needs to gate it. Each model declares which archive table its codes point into, so call sites stop repeating the table and the archive can find the portal records carrying a code. That gives the archive record a way back to the person or org, and an org's edit page a link to the FileMaker agency its project sits under. Codes the archive has no row for render struck through beside the input, flagging a likely typo instead of disappearing. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- app/controllers/fm_archives_controller.rb | 9 +++ app/helpers/filemaker_helper.rb | 47 +++++++++++++--- app/models/concerns/filemaker_coded.rb | 13 +++++ app/models/organization.rb | 1 + app/models/person.rb | 1 + app/views/fm_archives/show.html.erb | 11 ++++ app/views/organizations/_form.html.erb | 22 ++++---- app/views/organizations/edit.html.erb | 6 +- app/views/people/_admin_details.html.erb | 3 +- app/views/people/_form.html.erb | 13 ++--- app/views/people/edit.html.erb | 6 +- config/features.yml | 13 +++++ spec/helpers/filemaker_helper_spec.rb | 57 +++++++++++++++++++ spec/models/concerns/filemaker_coded_spec.rb | 22 ++++++++ spec/requests/fm_archives_spec.rb | 59 +++++++++++++++++--- 16 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 spec/helpers/filemaker_helper_spec.rb create mode 100644 spec/models/concerns/filemaker_coded_spec.rb 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..f734042943 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,17 @@ 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 } + 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..361c183c4f 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -1,18 +1,47 @@ 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). `label` prefixes each link, for a button or hint that + # has to read as an action. + def filemaker_code_links(record, link_class: nil, label: nil) + filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives, + link_class: link_class, label: label) + end + + # Compact archive buttons that sit beside a record's FileMaker code input. + def filemaker_code_chips(record) + filemaker_code_links(record, label: tag.i(class: "fa-solid fa-box-archive", aria: { hidden: true }), + link_class: button_classes(:primary_outline, size: :sm, extra: "bg-white")) + end + + # An org's codes are FileMaker projects; this links the agency each project sits under. + def filemaker_agency_links(organization, link_class: nil, label: nil) + agency_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq + filemaker_archive_links(agency_ids, archives: [ FmOrganization ], link_class: link_class, label: label) + 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. + def filemaker_archive_links(codes, archives:, link_class: nil, label: nil) + return if codes.empty? || !allowed_to?(:show?, :fm_archive) - stored = archive.where(fm_id: codes).pluck(:fm_id).to_set + stored = archives.index_with { |archive| archive.where(fm_id: codes).pluck(:fm_id).to_set } safe_join(codes.map do |code| - text = [ label, code ].compact.join(" ") - next tag.span(text) unless stored.include?(code) + text = safe_join([ label, code ].compact, " ") + archive = archives.find { |candidate| stored[candidate].include?(code) } + 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..5dddd09105 100644 --- a/app/models/concerns/filemaker_coded.rb +++ b/app/models/concerns/filemaker_coded.rb @@ -6,11 +6,24 @@ module FilemakerCoded extend ActiveSupport::Concern + included do + class_attribute :filemaker_archives, instance_writer: false, default: [] + 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(?, REPLACE(filemaker_code, ' ', ''))", code.to_s.strip) + end + def split_filemaker_codes(value) value.to_s.split(",").map(&:strip).reject(&:blank?).uniq end diff --git a/app/models/organization.rb b/app/models/organization.rb index 91af0f0cd4..6c3e9cb0d1 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 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..045cd9d12f 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -178,17 +178,17 @@ <% if allowed_to?(:manage?, Organization) %>
- <%= f.input :filemaker_code, - label: "FileMaker Project ID", - 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 %> +
+ <%= f.input :filemaker_code, + label: "FileMaker Project ID", + wrapper_html: { class: "flex-1" }, + input_html: { + class: "rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500" + } %> +
+ <%= filemaker_code_chips(f.object) %> +
+
<% 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..9536ee0f10 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", + <%= filemaker_code_links(@organization, label: "FM project", + link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> + <%= filemaker_agency_links(@organization, label: "FM agency", link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> - <% end %> <%= 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..ebc2743f77 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -464,17 +464,14 @@ wrapper_html: { class: "w-full" } %>
-
+
<%= f.input :filemaker_code, label: "FileMaker code", 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 %> + wrapper_html: { class: "w-full sm:w-64" } %> +
+ <%= filemaker_code_chips(f.object) %> +
diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 0120c27367..59c85e590f 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -35,10 +35,8 @@ <% 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 %> + <%= filemaker_code_links(@person, label: "Rolodex", + link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> <%= 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..75a03d426a 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4221,3 +4221,16 @@ 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: >- + Archive buttons now sit next to the FileMaker code on person and organization + edit pages, and each archive record links back to the portal person or + organization that carries its code. + pro_tips: + - "A struck-through code button means the archive has no record for that code β€” check it for a typo." + - "An organization's edit page also links the FileMaker agency its project belongs to." diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb new file mode 100644 index 0000000000..bacc88d2c4 --- /dev/null +++ b/spec/helpers/filemaker_helper_spec.rb @@ -0,0 +1,57 @@ +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 "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 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_agency_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 agency each of the org's projects belongs to" do + html = helper.filemaker_agency_links(organization, label: "FM agency") + + expect(html).to include(fm_archive_path("ORG07206", table: "organizations")) + expect(html).to include("FM agency ORG07206") + 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..f4bbd1f4ff --- /dev/null +++ b/spec/models/concerns/filemaker_coded_spec.rb @@ -0,0 +1,22 @@ +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 + 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 ]) + end + end +end diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index cc12f19b66..f9ebcdf326 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -3,22 +3,67 @@ 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 "lists a rolodex record's funding under related records" do - get fm_archive_path(rolodex.fm_id, table: "rolodexes") + it "links an archived project back to its portal organization" do + FmProject.create!(fm_id: "P2775") + organization = create(:organization, filemaker_code: "P2775") - expect(response.body).to include("Funding (1)", "table=fundings") + get fm_archive_path("P2775", table: "projects") + + expect(response.body).to include(organization_path(organization)) + 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_")) From 54ec7f03e313b239db2bb764a1605e2a983c8c19 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 17:08:02 -0400 Subject: [PATCH 03/17] Name a single-code archive button plainly and show missing codes struck through A nav button reading "Rolodex 00006" repeats the code already beside it; the code only helps when a record has several to tell apart. A missing code's underline was hiding its strikethrough, so it looked like a working link. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 24 ++++++++++++++---------- app/views/people/edit.html.erb | 4 ++-- spec/helpers/filemaker_helper_spec.rb | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 361c183c4f..f180b694b5 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -1,22 +1,25 @@ module FilemakerHelper # One archive link per FileMaker code a record carries, since a record can answer to - # several (see FilemakerCoded). `label` prefixes each link, for a button or hint that - # has to read as an action. + # 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) - filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives, - link_class: link_class, label: label) + codes = record.filemaker_codes + filemaker_archive_links(codes, archives: record.class.filemaker_archives, link_class: link_class, + label: label, show_code: label.nil? || codes.many?) end # Compact archive buttons that sit beside a record's FileMaker code input. def filemaker_code_chips(record) - filemaker_code_links(record, label: tag.i(class: "fa-solid fa-box-archive", aria: { hidden: true }), - link_class: button_classes(:primary_outline, size: :sm, extra: "bg-white")) + filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives, + label: tag.i(class: "fa-solid fa-box-archive", aria: { hidden: true }), + link_class: button_classes(:primary_outline, size: :sm, extra: "bg-white")) end # An org's codes are FileMaker projects; this links the agency each project sits under. def filemaker_agency_links(organization, link_class: nil, label: nil) agency_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq - filemaker_archive_links(agency_ids, archives: [ FmOrganization ], link_class: link_class, label: label) + filemaker_archive_links(agency_ids, archives: [ FmOrganization ], link_class: link_class, + label: label, show_code: label.nil? || agency_ids.many?) end # The way back from an archive row to the portal record carrying its code. @@ -30,15 +33,16 @@ def filemaker_portal_link(record) end # A code no archive table holds renders struck through, flagging a likely typo. - def filemaker_archive_links(codes, archives:, link_class: nil, label: nil) + def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, show_code: true) return if codes.empty? || !allowed_to?(:show?, :fm_archive) stored = archives.index_with { |archive| archive.where(fm_id: codes).pluck(:fm_id).to_set } safe_join(codes.map do |code| - text = safe_join([ label, code ].compact, " ") + text = show_code ? safe_join([ label, code ].compact, " ") : label archive = archives.find { |candidate| stored[candidate].include?(code) } - next tag.span(text, class: [ link_class, "line-through opacity-60" ], title: "No FileMaker archive record for #{code}") unless archive + # 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: archive.table_name.delete_prefix("fm_")), class: link_class, title: "Open #{code} in the FileMaker archive", target: "_blank", rel: "noopener" diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 59c85e590f..804be0ad31 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -32,11 +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", + 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 %> - <%= filemaker_code_links(@person, label: "Rolodex", - link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> <%= 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/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index bacc88d2c4..db635b07ad 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -19,6 +19,13 @@ 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) @@ -39,6 +46,16 @@ end end + describe "#filemaker_code_chips" do + before { FmRolodex.create!(fm_id: "00006") } + + it "shows the code even when the record carries a single code" do + html = helper.filemaker_code_chips(build(:person, filemaker_code: "00006")) + + expect(html).to include("00006") + end + end + describe "#filemaker_agency_links" do let(:organization) { build(:organization, filemaker_code: "P2775") } @@ -51,7 +68,7 @@ html = helper.filemaker_agency_links(organization, label: "FM agency") expect(html).to include(fm_archive_path("ORG07206", table: "organizations")) - expect(html).to include("FM agency ORG07206") + expect(html).to include(">FM agency") end end end From 6130db78b00a2f62b10cce0c15b2984e32cbd75a Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 17:20:31 -0400 Subject: [PATCH 04/17] List FileMaker archive links in the code field's hint Chips beside the input crowded the row; the hint keeps the links with the field they describe without widening it. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 9 ++++----- app/views/organizations/_form.html.erb | 17 ++++++----------- app/views/people/_form.html.erb | 8 +++----- config/features.yml | 6 +++--- spec/helpers/filemaker_helper_spec.rb | 12 +++++++++--- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index f180b694b5..773360e301 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -8,11 +8,10 @@ def filemaker_code_links(record, link_class: nil, label: nil) label: label, show_code: label.nil? || codes.many?) end - # Compact archive buttons that sit beside a record's FileMaker code input. - def filemaker_code_chips(record) - filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives, - label: tag.i(class: "fa-solid fa-box-archive", aria: { hidden: true }), - link_class: button_classes(:primary_outline, size: :sm, extra: "bg-white")) + # 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 # An org's codes are FileMaker projects; this links the agency each project sits under. diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 045cd9d12f..4d46c63341 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -178,17 +178,12 @@ <% if allowed_to?(:manage?, Organization) %>
-
- <%= f.input :filemaker_code, - label: "FileMaker Project ID", - wrapper_html: { class: "flex-1" }, - input_html: { - class: "rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> -
- <%= filemaker_code_chips(f.object) %> -
-
+ <%= f.input :filemaker_code, + label: "FileMaker Project 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" + } %> <% 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/people/_form.html.erb b/app/views/people/_form.html.erb index ebc2743f77..0b8e97086d 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -464,14 +464,12 @@ wrapper_html: { class: "w-full" } %>
-
+
<%= f.input :filemaker_code, label: "FileMaker code", + hint: filemaker_code_hint(f.object), input_html: { class: "w-full" }, - wrapper_html: { class: "w-full sm:w-64" } %> -
- <%= filemaker_code_chips(f.object) %> -
+ wrapper_html: { class: "w-full" } %>
diff --git a/config/features.yml b/config/features.yml index 75a03d426a..8aa7126e6d 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4228,9 +4228,9 @@ released_on: 2026-10-12 pr_number: 2648 summary: >- - Archive buttons now sit next to the FileMaker code on person and organization - edit pages, and each archive record links back to the portal person or + 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 button means the archive has no record for that code β€” check it for a typo." + - "A struck-through code means the archive has no record for that code β€” check it for a typo." - "An organization's edit page also links the FileMaker agency its project belongs to." diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index db635b07ad..d4f5ded519 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -46,14 +46,20 @@ end end - describe "#filemaker_code_chips" do + describe "#filemaker_code_hint" do before { FmRolodex.create!(fm_id: "00006") } - it "shows the code even when the record carries a single code" do - html = helper.filemaker_code_chips(build(:person, filemaker_code: "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_agency_links" do From 9fc5b1d904841af9bfee9f2824732af1b8b82d1c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 18:08:39 -0400 Subject: [PATCH 05/17] Read FileMaker codes as comma-separated, ignoring any whitespace Codes pasted from FileMaker or a spreadsheet carry tabs, line breaks, and non-breaking spaces that broke both the archive links and the archive's link back. Saving stores the sorted comma-space form, so edited records settle into one shape without a backfill. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/concerns/filemaker_coded.rb | 15 ++++++--- config/features.yml | 1 + spec/models/concerns/filemaker_coded_spec.rb | 32 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/models/concerns/filemaker_coded.rb b/app/models/concerns/filemaker_coded.rb index 5dddd09105..e038270d95 100644 --- a/app/models/concerns/filemaker_coded.rb +++ b/app/models/concerns/filemaker_coded.rb @@ -1,13 +1,16 @@ # 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 @@ -21,11 +24,15 @@ def filemaker_archived_in(*archives) end def with_filemaker_code(code) - where("FIND_IN_SET(?, REPLACE(filemaker_code, ' ', ''))", code.to_s.strip) + where("FIND_IN_SET(?, REGEXP_REPLACE(filemaker_code, '[[:space:]]', ''))", strip_filemaker_code(code)) 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/config/features.yml b/config/features.yml index 8aa7126e6d..0575749c5e 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4233,4 +4233,5 @@ 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 agency its project belongs to." diff --git a/spec/models/concerns/filemaker_coded_spec.rb b/spec/models/concerns/filemaker_coded_spec.rb index f4bbd1f4ff..30a46faa08 100644 --- a/spec/models/concerns/filemaker_coded_spec.rb +++ b/spec/models/concerns/filemaker_coded_spec.rb @@ -11,6 +11,38 @@ 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 "#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 From 47768c1eca4eaecef898e5da44848fd88ab612a9 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 18:11:25 -0400 Subject: [PATCH 06/17] Call the org's parent FileMaker record an FM organization That's the name the archive uses for the table it opens. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 10 +++++----- app/views/organizations/edit.html.erb | 4 ++-- config/features.yml | 2 +- spec/helpers/filemaker_helper_spec.rb | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 773360e301..534616736b 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -14,11 +14,11 @@ def filemaker_code_hint(record) safe_join([ "In the archive:", links ], " ") if links end - # An org's codes are FileMaker projects; this links the agency each project sits under. - def filemaker_agency_links(organization, link_class: nil, label: nil) - agency_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq - filemaker_archive_links(agency_ids, archives: [ FmOrganization ], link_class: link_class, - label: label, show_code: label.nil? || agency_ids.many?) + # An org's codes are FileMaker projects; this links the FM organization each project sits under. + def filemaker_organization_links(organization, link_class: nil, label: nil) + fm_organization_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq + filemaker_archive_links(fm_organization_ids, archives: [ FmOrganization ], link_class: link_class, + label: label, show_code: label.nil? || fm_organization_ids.many?) end # The way back from an archive row to the portal record carrying its code. diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index 9536ee0f10..91f5525fca 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -11,8 +11,8 @@ <% actions = capture do %> <%= filemaker_code_links(@organization, label: "FM project", link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> - <%= filemaker_agency_links(@organization, label: "FM agency", - link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %> + <%= filemaker_organization_links(@organization, label: "FM organization", + 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/config/features.yml b/config/features.yml index 0575749c5e..ee7e98db25 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4234,4 +4234,4 @@ 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 agency its project belongs to." + - "An organization's edit page also links the FileMaker organization its project belongs to." diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index d4f5ded519..a5bd225b14 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -62,7 +62,7 @@ end end - describe "#filemaker_agency_links" do + describe "#filemaker_organization_links" do let(:organization) { build(:organization, filemaker_code: "P2775") } before do @@ -70,11 +70,11 @@ FmOrganization.create!(fm_id: "ORG07206") end - it "links the agency each of the org's projects belongs to" do - html = helper.filemaker_agency_links(organization, label: "FM agency") + 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 agency") + expect(html).to include(">FM organization") end end end From 24c6f3de74764c9ce559460be4fbc378ec9068ce Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 18:50:14 -0400 Subject: [PATCH 07/17] Show an edit page's FileMaker nav button only when it leads somewhere A nav button for a code the archive has no row for rendered struck through and went nowhere. The top bar now drops those; the code field's hint keeps showing them struck through, where the typo gets fixed. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 26 +++++++++++++---------- app/views/organizations/edit.html.erb | 4 ++-- app/views/people/edit.html.erb | 2 +- spec/helpers/filemaker_helper_spec.rb | 30 +++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 534616736b..357e12656c 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -2,10 +2,9 @@ module FilemakerHelper # One archive link per FileMaker code a record carries, since a record can answer to # 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) - codes = record.filemaker_codes - filemaker_archive_links(codes, archives: record.class.filemaker_archives, link_class: link_class, - label: label, show_code: label.nil? || codes.many?) + 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. @@ -15,10 +14,10 @@ def filemaker_code_hint(record) end # An org's codes are FileMaker projects; this links the FM organization each project sits under. - def filemaker_organization_links(organization, link_class: nil, label: nil) + def filemaker_organization_links(organization, link_class: nil, label: nil, linked_only: false) fm_organization_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq - filemaker_archive_links(fm_organization_ids, archives: [ FmOrganization ], link_class: link_class, - label: label, show_code: label.nil? || fm_organization_ids.many?) + 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. @@ -31,15 +30,20 @@ def filemaker_portal_link(record) 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. - def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, show_code: true) + # 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? - safe_join(codes.map do |code| + show_code = label.nil? || archive_by_code.many? + + safe_join(archive_by_code.map do |code, archive| text = show_code ? safe_join([ label, code ].compact, " ") : label - archive = archives.find { |candidate| stored[candidate].include?(code) } # 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 diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index 91f5525fca..eb3079eddb 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -9,9 +9,9 @@ <% end %> <% end %> <% actions = capture do %> - <%= filemaker_code_links(@organization, label: "FM project", + <%= filemaker_code_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", + <%= 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" %> diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb index 804be0ad31..0715d3a882 100644 --- a/app/views/people/edit.html.erb +++ b/app/views/people/edit.html.erb @@ -32,7 +32,7 @@ <% 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", + <%= 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" %> diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index a5bd225b14..619847cb11 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -37,6 +37,28 @@ 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 } @@ -76,5 +98,13 @@ 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 end end From 4976121312f6d6d06ca8cd7cb6438213917d4052 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 19:18:00 -0400 Subject: [PATCH 08/17] Point ADR-0005 at filemaker_archived_in for new archive links Archive links read the table from the model's declaration, so the ADR's per-call archive:/table: example no longer works. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0005-filemaker-code-archive-mapping.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 934aeebddc..263dcc62ec 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -41,5 +41,6 @@ drive the archive pages' links. ## 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, or workshops includes `FilemakerCoded` on the +model and declares the archive table above (e.g. `filemaker_archived_in FmPersonnel`); +`filemaker_code_links` reads it from there. From 3cc41d5317b1206ebc99e868c836f8335f919506 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 19:56:45 -0400 Subject: [PATCH 09/17] Match an org's FileMaker code in full against projects or FM organizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An org can carry an ORG… code as well as a P… one, and the prefix is part of the id β€” P00023 and ORG00023 are different records β€” so codes are looked up whole, never by their number. ADR-0005 now records each archive table's id pattern as we learn them, since prefixes overlap (P is both projects and participants). Co-Authored-By: Claude Opus 5.5 (1M context) --- app/helpers/filemaker_helper.rb | 12 +++- app/models/organization.rb | 2 +- app/views/organizations/_form.html.erb | 2 +- app/views/organizations/edit.html.erb | 4 +- config/features.yml | 1 + .../0005-filemaker-code-archive-mapping.md | 70 +++++++++++++++++-- spec/helpers/filemaker_helper_spec.rb | 41 +++++++++++ spec/models/concerns/filemaker_coded_spec.rb | 2 +- spec/requests/fm_archives_spec.rb | 11 +++ 9 files changed, 134 insertions(+), 11 deletions(-) diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb index 357e12656c..dbcdea1fea 100644 --- a/app/helpers/filemaker_helper.rb +++ b/app/helpers/filemaker_helper.rb @@ -13,9 +13,17 @@ def filemaker_code_hint(record) safe_join([ "In the archive:", links ], " ") if links end - # An org's codes are FileMaker projects; this links the FM organization each project sits under. + # 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) - fm_organization_ids = FmProject.where(fm_id: organization.filemaker_codes).filter_map { |project| project.data["OrgID"].presence }.uniq + 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 diff --git a/app/models/organization.rb b/app/models/organization.rb index 6c3e9cb0d1..a507aa8dae 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -2,7 +2,7 @@ class Organization < ApplicationRecord include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable include Communicable include FilemakerCoded - filemaker_archived_in FmProject + 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/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb index 4d46c63341..572f087472 100644 --- a/app/views/organizations/_form.html.erb +++ b/app/views/organizations/_form.html.erb @@ -179,7 +179,7 @@ <% 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" diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb index eb3079eddb..7d73eac082 100644 --- a/app/views/organizations/edit.html.erb +++ b/app/views/organizations/edit.html.erb @@ -9,8 +9,8 @@ <% end %> <% end %> <% actions = capture do %> - <%= filemaker_code_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_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" %> diff --git a/config/features.yml b/config/features.yml index ee7e98db25..86b08a0409 100644 --- a/config/features.yml +++ b/config/features.yml @@ -4235,3 +4235,4 @@ - "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 263dcc62ec..9f753960d3 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -24,21 +24,83 @@ 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 | +| `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_personnels` | `PrsnlRecID`, e.g. `PS9999` | dev seed (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 | -- **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). +- **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. The org edit page also links the + FM organization each of its projects sits under (`Project.OrgID`). +- **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 β†’ 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. +## Archive id patterns + +What each archive table's `fm_id` looks like, as far as we know. Add to it as we +learn more from the export. Evidence is each `app/models/fm_*.rb` header (PK and FK +examples); "?" means we haven't seen one yet. + +| Archive table | FileMaker key | Id pattern | Example | Portal column | +|---|---|---|---|---| +| `fm_activities` | `ActivityLogID` | ? | | | +| `fm_addresses` | `AddrsID` | ? | | | +| `fm_allocations` | `AllocRecID` | ? | | | +| `fm_ancillary_data` | `RecordID` | ? | | | +| `fm_art_purchases` | `PurchaseID` | ? | | | +| `fm_art_registries` | `ItemID` | ? | | | +| `fm_campaigns` | `CampaignID` | ? | | | +| `fm_committee_meetings` | `CtMtgID` | ? | | | +| `fm_committee_members` | `CMRecID` | ? | | | +| `fm_committees` | `CommitteeID` | ? | | | +| `fm_contacts` | `ContactID` | ? | | | +| `fm_donor_developers` | `DonorDevRecID` | ? | | | +| `fm_email_addresses` | `EmailAddrsID` | ? | | | +| `fm_events` | `EventID` | `E` + digits | `E4037` | | +| `fm_exhibited_items` | `RecordID` | ? | | | +| `fm_expenditures` | `ExpendRecID` | `X` + digits | `X5510` | | +| `fm_form_fields` | `FieldNo` | ? | | | +| `fm_form_letters` | `DocID` | ? | | | +| `fm_form_submissions` | `RecordID` | likely `FSB` + 5 digits (seen only as refs from payments/personnel) | `FSB03117` | | +| `fm_forms_lists` | `FormID` | ? | | | +| `fm_fundings` | `RecordID` | `F` + digits | `F1581` | | +| `fm_group_members` | `RecordID` | ? | | | +| `fm_groups` | `GroupID` | ? | | | +| `fm_invoices` | `RecordID` | ? | | | +| `fm_line_items` | `ItemID` | ? | | | +| `fm_match_donations` | `RecordID` | ? | | | +| `fm_notes` | `NoteID` | `N` + 5 digits | `N00643` | | +| `fm_organizations` | `OrgID` | `ORG` + 5 digits | `ORG07206` | `organizations` | +| `fm_participants` | `PRecID` | `P` + digits (same prefix as projects) | `P52646` | | +| `fm_payments` | `RecordID` | `D` + digits | `D29186` | `payments` | +| `fm_people_to_organizations` | `RecordID` | ? | | | +| `fm_personnels` | `PrsnlRecID` | `PS` + digits | `PS9999`, `PS10005` | `affiliations` | +| `fm_phone_numbers` | `PhoneID` | ? | | | +| `fm_postal_codes` | `Zipcode` | the ZIP code | | | +| `fm_program_sponsorships` | `RecordID` | `PSP` + 4 digits | `PSP0901` | | +| `fm_projects` | `ProjectID` | `P` + digits | `P2775` | `organizations` | +| `fm_quotations` | `RecordID` | ? | | | +| `fm_reportings` | `ReportID` | ? | | | +| `fm_rolodexes` | `ID` | digits only, zero-padded to 5 (`6` β‰  `00006`) | `00006` | `people` | +| `fm_services` | `RecordID` | ? | | | +| `fm_solicitations` | `SolicitationID` | ? | | | +| `fm_volunteers` | `VolID` | ? | | | +| `fm_workshop_logs` | `RecordID` | ? | | | +| `fm_workshops` | `WorkshopID` | `W` + 4 digits | `W0421` | `workshops` | + +Prefixes overlap: `P` is both projects and participants, and `P` / `PS` / `PSP` nest. +That's why a code is only ever looked up, in full, in the tables its portal column +declares. + ## Consequences A UI link for affiliations, payments, or workshops includes `FilemakerCoded` on the diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb index 619847cb11..4485c1a52b 100644 --- a/spec/helpers/filemaker_helper_spec.rb +++ b/spec/helpers/filemaker_helper_spec.rb @@ -106,5 +106,46 @@ 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 index 30a46faa08..0b7b6cfcb8 100644 --- a/spec/models/concerns/filemaker_coded_spec.rb +++ b/spec/models/concerns/filemaker_coded_spec.rb @@ -48,7 +48,7 @@ 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 ]) + 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 f9ebcdf326..04d5fa2957 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -39,6 +39,17 @@ expect(response.body).to include(organization_path(organization)) end + 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") + + 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 "omits the portal section when nothing carries the code" do FmRolodex.create!(fm_id: "00006") From 1acc10665af24f503451463629903c496266db50 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:00:30 -0400 Subject: [PATCH 10/17] Record production FileMaker id patterns in ADR-0005 Profiled every archive table's fm_id shape in prod. Project ids are all also valid participant ids, and X/D/numeric ids collide across tables too, which is why codes are looked up whole in their declared tables rather than routed by prefix. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0005-filemaker-code-archive-mapping.md | 117 ++++++++++-------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 9f753960d3..333763d37d 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -46,60 +46,73 @@ drive the archive pages' links. ## Archive id patterns -What each archive table's `fm_id` looks like, as far as we know. Add to it as we -learn more from the export. Evidence is each `app/models/fm_*.rb` header (PK and FK -examples); "?" means we haven't seen one yet. +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 | Example | Portal column | -|---|---|---|---|---| -| `fm_activities` | `ActivityLogID` | ? | | | -| `fm_addresses` | `AddrsID` | ? | | | -| `fm_allocations` | `AllocRecID` | ? | | | -| `fm_ancillary_data` | `RecordID` | ? | | | -| `fm_art_purchases` | `PurchaseID` | ? | | | -| `fm_art_registries` | `ItemID` | ? | | | -| `fm_campaigns` | `CampaignID` | ? | | | -| `fm_committee_meetings` | `CtMtgID` | ? | | | -| `fm_committee_members` | `CMRecID` | ? | | | -| `fm_committees` | `CommitteeID` | ? | | | -| `fm_contacts` | `ContactID` | ? | | | -| `fm_donor_developers` | `DonorDevRecID` | ? | | | -| `fm_email_addresses` | `EmailAddrsID` | ? | | | -| `fm_events` | `EventID` | `E` + digits | `E4037` | | -| `fm_exhibited_items` | `RecordID` | ? | | | -| `fm_expenditures` | `ExpendRecID` | `X` + digits | `X5510` | | -| `fm_form_fields` | `FieldNo` | ? | | | -| `fm_form_letters` | `DocID` | ? | | | -| `fm_form_submissions` | `RecordID` | likely `FSB` + 5 digits (seen only as refs from payments/personnel) | `FSB03117` | | -| `fm_forms_lists` | `FormID` | ? | | | -| `fm_fundings` | `RecordID` | `F` + digits | `F1581` | | -| `fm_group_members` | `RecordID` | ? | | | -| `fm_groups` | `GroupID` | ? | | | -| `fm_invoices` | `RecordID` | ? | | | -| `fm_line_items` | `ItemID` | ? | | | -| `fm_match_donations` | `RecordID` | ? | | | -| `fm_notes` | `NoteID` | `N` + 5 digits | `N00643` | | -| `fm_organizations` | `OrgID` | `ORG` + 5 digits | `ORG07206` | `organizations` | -| `fm_participants` | `PRecID` | `P` + digits (same prefix as projects) | `P52646` | | -| `fm_payments` | `RecordID` | `D` + digits | `D29186` | `payments` | -| `fm_people_to_organizations` | `RecordID` | ? | | | -| `fm_personnels` | `PrsnlRecID` | `PS` + digits | `PS9999`, `PS10005` | `affiliations` | -| `fm_phone_numbers` | `PhoneID` | ? | | | -| `fm_postal_codes` | `Zipcode` | the ZIP code | | | -| `fm_program_sponsorships` | `RecordID` | `PSP` + 4 digits | `PSP0901` | | -| `fm_projects` | `ProjectID` | `P` + digits | `P2775` | `organizations` | -| `fm_quotations` | `RecordID` | ? | | | -| `fm_reportings` | `ReportID` | ? | | | -| `fm_rolodexes` | `ID` | digits only, zero-padded to 5 (`6` β‰  `00006`) | `00006` | `people` | -| `fm_services` | `RecordID` | ? | | | -| `fm_solicitations` | `SolicitationID` | ? | | | -| `fm_volunteers` | `VolID` | ? | | | -| `fm_workshop_logs` | `RecordID` | ? | | | -| `fm_workshops` | `WorkshopID` | `W` + 4 digits | `W0421` | `workshops` | +| 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` | -Prefixes overlap: `P` is both projects and participants, and `P` / `PS` / `PSP` nest. -That's why a code is only ever looked up, in full, in the tables its portal column -declares. +### 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. ## Consequences From b06acaca9800c969cd06fbcc7c5d01ede0e1a2d2 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:03:07 -0400 Subject: [PATCH 11/17] Record what FileMaker codes portal records hold in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Affiliations almost all carry project codes and resources carry quotation codes, which contradicts the mapping ADR-0005 recorded for them β€” flagged open rather than flipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0005-filemaker-code-archive-mapping.md | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 333763d37d..f383729bda 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -39,10 +39,13 @@ drive the archive pages' links. 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 β†’ Personnel.** Personnel is a person on a project with a role and - agreement status. With orgs = projects, that's an affiliation. + agreement status. With orgs = projects, that's an affiliation. **Open:** production + affiliations almost all carry a *project* code instead (see "What portal records + store" below). - **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. + seed leaves `resources.filemaker_code` blank. **Open:** most production resource + codes are quotation ids (`Q…`), so some resources may map to `fm_quotations`. ## Archive id patterns @@ -114,6 +117,24 @@ or shape. - **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 `PS` (personnel) | 1,531 `P` + 4 digits (**projects**), plus `ORG05589`, `PP208`, `09856` | +| `payments` | 0 | β€” | β€” | +| `workshops` | 850 | 849 `W` + 4 digits | `XVI/4` | +| `resources` | 128 | (unmapped) | 116 `Q` + 4–5 digits (**quotations**), plus bare 2–3 digit numbers, `v`, and a URL | + +Outliers get fixed by hand, not backfilled: an admin works from the list of portal +records whose codes have no row in the archive tables their column maps to. Those +codes already show struck through in the code field's hint. + ## Consequences A UI link for affiliations, payments, or workshops includes `FilemakerCoded` on the From 80c64d45947ee5ca8dd991fdca5d782248ead215 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:08:08 -0400 Subject: [PATCH 12/17] Mark affiliations' P-codes as unverified in ADR-0005 Their shape fits a project id, but a personnel id missing its S fits too; only checking each row against its person and org can tell. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0005-filemaker-code-archive-mapping.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index f383729bda..237c866379 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -40,8 +40,9 @@ drive the archive pages' links. prefixes aren't unique to one table (see below), so nothing strips or routes by it. - **Affiliation β†’ Personnel.** Personnel is a person on a project with a role and agreement status. With orgs = projects, that's an affiliation. **Open:** production - affiliations almost all carry a *project* code instead (see "What portal records - store" below). + affiliations almost all carry `P` + 4-digit codes, which could be project ids or + personnel ids missing their `S` β€” not yet checked against each row's person and org + (see "What portal records store" below). - **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. **Open:** most production resource @@ -126,7 +127,7 @@ split into single codes). Compare against the grid above. |---|---|---|---| | `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 `PS` (personnel) | 1,531 `P` + 4 digits (**projects**), plus `ORG05589`, `PP208`, `09856` | +| `affiliations` | 1,535 | 1 `PS` (personnel) | 1,531 `P` + 4 digits (project ids, or personnel ids missing the `S` β€” unverified), plus `ORG05589`, `PP208`, `09856` | | `payments` | 0 | β€” | β€” | | `workshops` | 850 | 849 `W` + 4 digits | `XVI/4` | | `resources` | 128 | (unmapped) | 116 `Q` + 4–5 digits (**quotations**), plus bare 2–3 digit numbers, `v`, and a URL | From 1de431f9ae824f314ddd3953bc289e09c5cdbf54 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:14:00 -0400 Subject: [PATCH 13/17] List an FM organization's portal orgs through its projects too Nearly every org carries a project code, not an ORG code, so the archive page for an FM organization showed no portal orgs. It now also finds orgs carrying any of its projects' codes, in one query. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/fm_archives_controller.rb | 7 +++++++ app/models/concerns/filemaker_coded.rb | 4 ++++ docs/adr/0005-filemaker-code-archive-mapping.md | 6 ++++-- spec/models/concerns/filemaker_coded_spec.rb | 16 ++++++++++++++++ spec/requests/fm_archives_spec.rb | 15 +++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/controllers/fm_archives_controller.rb b/app/controllers/fm_archives_controller.rb index f734042943..77b4e7aa18 100644 --- a/app/controllers/fm_archives_controller.rb +++ b/app/controllers/fm_archives_controller.rb @@ -103,6 +103,13 @@ 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 diff --git a/app/models/concerns/filemaker_coded.rb b/app/models/concerns/filemaker_coded.rb index e038270d95..aa25669daf 100644 --- a/app/models/concerns/filemaker_coded.rb +++ b/app/models/concerns/filemaker_coded.rb @@ -27,6 +27,10 @@ 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 { |code| strip_filemaker_code(code) }.reject(&:empty?).uniq end diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 237c866379..d2ddd0cd95 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -33,8 +33,10 @@ drive the archive pages' links. - **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. The org edit page also links the - FM organization each of its projects sits under (`Project.OrgID`). + 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. diff --git a/spec/models/concerns/filemaker_coded_spec.rb b/spec/models/concerns/filemaker_coded_spec.rb index 0b7b6cfcb8..6098393b91 100644 --- a/spec/models/concerns/filemaker_coded_spec.rb +++ b/spec/models/concerns/filemaker_coded_spec.rb @@ -20,6 +20,22 @@ 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) diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb index 04d5fa2957..e4e16165e5 100644 --- a/spec/requests/fm_archives_spec.rb +++ b/spec/requests/fm_archives_spec.rb @@ -50,6 +50,21 @@ 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") From 0c45237a041cb038d6b662b8892f74d2f212c361 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:16:36 -0400 Subject: [PATCH 14/17] Map resource FileMaker codes to quotations in ADR-0005 Production resource codes are almost all quotation ids on Leader spotlight resources, and nearly all of them exist in the quotations archive. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0005-filemaker-code-archive-mapping.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index d2ddd0cd95..43a36ee56e 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -28,7 +28,7 @@ drive the archive pages' links. | `affiliations.filemaker_code` | `fm_personnels` | `PrsnlRecID`, e.g. `PS9999` | dev seed (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, or β†’ Organization.** An app `Organization` is usually one art program site, which is a FileMaker *Project*. FileMaker's own Organization row is @@ -45,10 +45,11 @@ drive the archive pages' links. affiliations almost all carry `P` + 4-digit codes, which could be project ids or personnel ids missing their `S` β€” not yet checked against each row's person and org (see "What portal records store" below). -- **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. **Open:** most production resource - codes are quotation ids (`Q…`), so some resources may map to `fm_quotations`. +- **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 @@ -132,7 +133,7 @@ split into single codes). Compare against the grid above. | `affiliations` | 1,535 | 1 `PS` (personnel) | 1,531 `P` + 4 digits (project ids, or personnel ids missing the `S` β€” unverified), plus `ORG05589`, `PP208`, `09856` | | `payments` | 0 | β€” | β€” | | `workshops` | 850 | 849 `W` + 4 digits | `XVI/4` | -| `resources` | 128 | (unmapped) | 116 `Q` + 4–5 digits (**quotations**), plus bare 2–3 digit numbers, `v`, and a URL | +| `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: an admin works from the list of portal records whose codes have no row in the archive tables their column maps to. Those @@ -140,6 +141,6 @@ codes already show struck through in the code field's hint. ## Consequences -A UI link for affiliations, payments, or workshops includes `FilemakerCoded` on the +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. From 952d30e603218e2153732851898acaa61167b975 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:18:25 -0400 Subject: [PATCH 15/17] Map affiliation FileMaker codes to projects in ADR-0005 Checked against each affiliation's own person and org in production: the P-codes are the org's project id, not Personnel ids missing their S. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/adr/0005-filemaker-code-archive-mapping.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 43a36ee56e..164ebd2b36 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -25,7 +25,7 @@ drive the archive pages' links. |---|---|---|---| | `people.filemaker_code` | `fm_rolodexes` | `ID`, e.g. `00006` | `filemaker_code_links` on person edit + admin details | | `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_personnels` | `PrsnlRecID`, e.g. `PS9999` | dev seed (no UI link yet) | +| `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` | `fm_quotations` | `RecordID`, e.g. `Q10215` | no UI link yet | @@ -40,11 +40,13 @@ drive the archive pages' links. - **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 β†’ Personnel.** Personnel is a person on a project with a role and - agreement status. With orgs = projects, that's an affiliation. **Open:** production - affiliations almost all carry `P` + 4-digit codes, which could be project ids or - personnel ids missing their `S` β€” not yet checked against each row's person and org - (see "What portal records store" below). +- **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). @@ -130,7 +132,7 @@ split into single codes). Compare against the grid above. |---|---|---|---| | `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 `PS` (personnel) | 1,531 `P` + 4 digits (project ids, or personnel ids missing the `S` β€” unverified), plus `ORG05589`, `PP208`, `09856` | +| `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 | From 1f2b9d125f6f418b27ff8e0f4ec07fcddf289333 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:32:30 -0400 Subject: [PATCH 16/17] Add hand-fix guidance for bad FileMaker codes to ADR-0005 Each issue type gets a fix; the affected records will be listed by id only, so the ADR never names a record or repeats its code. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0005-filemaker-code-archive-mapping.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index 164ebd2b36..a6808a7d3c 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -137,9 +137,24 @@ split into single codes). Compare against the grid above. | `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: an admin works from the list of portal -records whose codes have no row in the archive tables their column maps to. Those -codes already show struck through in the code field's hint. +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. | +| 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). | + +Ids: _pending the next production run._ ## Consequences From 88754c1bdfea5d9184b81c791caccdba90fcfa39 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Mon, 12 Oct 2026 20:34:21 -0400 Subject: [PATCH 17/17] List the records with FileMaker codes to fix by hand in ADR-0005 Ids and issue type only, from a read-only production run, so the ADR names no record and repeats no code. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../0005-filemaker-code-archive-mapping.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md index a6808a7d3c..55643d059c 100644 --- a/docs/adr/0005-filemaker-code-archive-mapping.md +++ b/docs/adr/0005-filemaker-code-archive-mapping.md @@ -150,11 +150,26 @@ FileMaker code field. |---|---| | 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. | +| 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). | -Ids: _pending the next production run._ +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