diff --git a/AGENTS.md b/AGENTS.md
index fa22fbe5ab..066e78c386 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -146,7 +146,7 @@ This codebase (Rails 8.1)
| `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` |
| `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named |
| `Featureable` | `featured`, `publicly_featured` scopes |
-| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). Nothing should compare the whole column against a single code β the `filemaker_code_links` helper renders one archive link per code |
+| `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code β `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code |
| `Mentioner` | ActionText @mention extraction and grouping |
| `NameFilterable` | Name-based filtering |
| `PayChargeExtensions` | Included into `Pay::Charge` (via `config/initializers/pay.rb`): turns a Stripe charge into an AWBW `Payment` + `Allocation`, resolving the membership invoice it pays |
diff --git a/app/controllers/fm_archives_controller.rb b/app/controllers/fm_archives_controller.rb
index 1d1c0369fe..77b4e7aa18 100644
--- a/app/controllers/fm_archives_controller.rb
+++ b/app/controllers/fm_archives_controller.rb
@@ -63,6 +63,8 @@ class FmArchivesController < ApplicationController
{ label: "Other", icon: "π", tables: %w[notes] }
]
+ PORTAL_MODELS = [ Person, Organization ].freeze
+
def index
authorize! :fm_archive
@@ -92,10 +94,24 @@ def show
end
load_backlinks
+ load_portal_records
end
private
+ def load_portal_records
+ @portal_records = PORTAL_MODELS
+ .select { |model| model.filemaker_archives.include?(@record.class) }
+ .flat_map { |model| model.with_filemaker_code(@record.fm_id).to_a }
+ @portal_records |= organizations_via_projects if @record.is_a?(FmOrganization)
+ end
+
+ # Most orgs carry a project code, reaching their FM organization through Project.OrgID.
+ def organizations_via_projects
+ project_ids = FmProject.where("data->>'$.OrgID' = ?", @record.fm_id).pluck(:fm_id)
+ Organization.with_any_filemaker_code(project_ids).to_a
+ end
+
def load_backlinks
@backlinks = {}
return unless @record.class.const_defined?(:HAS_MANY)
diff --git a/app/helpers/filemaker_helper.rb b/app/helpers/filemaker_helper.rb
index e5fff83072..dbcdea1fea 100644
--- a/app/helpers/filemaker_helper.rb
+++ b/app/helpers/filemaker_helper.rb
@@ -1,18 +1,62 @@
module FilemakerHelper
# One archive link per FileMaker code a record carries, since a record can answer to
- # several (see FilemakerCoded). A code the archive has no row for renders as plain
- # text. `label` prefixes each link, for a button or hint that has to read as an action.
- def filemaker_code_links(record, archive:, table:, link_class: nil, label: nil)
- codes = record.filemaker_codes
- return if codes.empty?
+ # several (see FilemakerCoded). A `label` names the button, adding each code only when
+ # there are several to tell apart.
+ def filemaker_code_links(record, link_class: nil, label: nil, linked_only: false)
+ filemaker_archive_links(record.filemaker_codes, archives: record.class.filemaker_archives,
+ link_class: link_class, label: label, linked_only: linked_only)
+ end
+
+ # The hint under a FileMaker code input, linking each code to its archive record.
+ def filemaker_code_hint(record)
+ links = filemaker_code_links(record, link_class: "text-blue-600 underline hover:text-blue-800")
+ safe_join([ "In the archive:", links ], " ") if links
+ end
+
+ # The FM projects among an org's codes.
+ def filemaker_project_links(organization, link_class: nil, label: nil, linked_only: false)
+ project_ids = FmProject.where(fm_id: organization.filemaker_codes).pluck(:fm_id)
+ filemaker_archive_links(project_ids, archives: [ FmProject ], link_class: link_class, label: label, linked_only: linked_only)
+ end
+
+ # The FM organizations among an org's codes, plus the one each of its projects sits under.
+ def filemaker_organization_links(organization, link_class: nil, label: nil, linked_only: false)
+ codes = organization.filemaker_codes
+ parent_ids = FmProject.where(fm_id: codes).filter_map { |project| project.data["OrgID"].presence }
+ fm_organization_ids = (FmOrganization.where(fm_id: codes).pluck(:fm_id) + parent_ids).uniq
+ filemaker_archive_links(fm_organization_ids, archives: [ FmOrganization ],
+ link_class: link_class, label: label, linked_only: linked_only)
+ end
+
+ # The way back from an archive row to the portal record carrying its code.
+ def filemaker_portal_link(record)
+ text, path = case record
+ when Person then [ record.full_name, person_path(record) ]
+ when Organization then [ record.name, organization_path(record) ]
+ end
+
+ link_to text, path, class: button_classes(:primary_outline, size: :sm)
+ end
+
+ # A code no archive table holds renders struck through, flagging a likely typo β
+ # or is dropped with `linked_only`, for nav buttons that must all lead somewhere.
+ def filemaker_archive_links(codes, archives:, link_class: nil, label: nil, linked_only: false)
+ return if codes.empty? || !allowed_to?(:show?, :fm_archive)
+
+ stored = archives.index_with { |archive| archive.where(fm_id: codes).pluck(:fm_id).to_set }
+ archive_by_code = codes.index_with { |code| archives.find { |candidate| stored[candidate].include?(code) } }
+ archive_by_code.compact! if linked_only
+ return if archive_by_code.empty?
- stored = archive.where(fm_id: codes).pluck(:fm_id).to_set
+ show_code = label.nil? || archive_by_code.many?
- safe_join(codes.map do |code|
- text = [ label, code ].compact.join(" ")
- next tag.span(text) unless stored.include?(code)
+ safe_join(archive_by_code.map do |code, archive|
+ text = show_code ? safe_join([ label, code ].compact, " ") : label
+ # line-through! outranks a link_class `underline`, which sets the same text-decoration-line.
+ next tag.span(text, class: [ link_class, "line-through! opacity-60" ], title: "No FileMaker archive record for #{code}") unless archive
- link_to text, fm_archive_path(code, table: table), class: link_class, target: "_blank", rel: "noopener"
+ link_to text, fm_archive_path(code, table: archive.table_name.delete_prefix("fm_")),
+ class: link_class, title: "Open #{code} in the FileMaker archive", target: "_blank", rel: "noopener"
end, " ")
end
end
diff --git a/app/models/concerns/filemaker_coded.rb b/app/models/concerns/filemaker_coded.rb
index 313f2e2a24..aa25669daf 100644
--- a/app/models/concerns/filemaker_coded.rb
+++ b/app/models/concerns/filemaker_coded.rb
@@ -1,18 +1,42 @@
# One record can answer to more than one FileMaker code β two records that each mapped
# to a FileMaker row get merged, or the archive itself holds several rows for one
-# person. The `filemaker_code` column holds them as a trimmed, comma-separated list;
+# person. The `filemaker_code` column holds them as a sorted, comma-separated list;
# this is the seam for reading and combining them, so nothing compares the whole
-# column against a single code.
+# column against a single code. Codes never hold whitespace, so any (tabs, line
+# breaks, pasted non-breaking spaces) is dropped.
module FilemakerCoded
extend ActiveSupport::Concern
+ included do
+ class_attribute :filemaker_archives, instance_writer: false, default: []
+
+ normalizes :filemaker_code, with: ->(value) { join_filemaker_codes(value) }, apply_to_nil: true
+ end
+
def filemaker_codes
self.class.split_filemaker_codes(filemaker_code)
end
class_methods do
+ # The Fm* archive tables this model's codes point into, checked in order.
+ def filemaker_archived_in(*archives)
+ self.filemaker_archives = archives
+ end
+
+ def with_filemaker_code(code)
+ where("FIND_IN_SET(?, REGEXP_REPLACE(filemaker_code, '[[:space:]]', ''))", strip_filemaker_code(code))
+ end
+
+ def with_any_filemaker_code(codes)
+ codes.map { |code| with_filemaker_code(code) }.reduce(:or) || none
+ end
+
def split_filemaker_codes(value)
- value.to_s.split(",").map(&:strip).reject(&:blank?).uniq
+ value.to_s.split(",").map { |code| strip_filemaker_code(code) }.reject(&:empty?).uniq
+ end
+
+ def strip_filemaker_code(code)
+ code.to_s.gsub(/[[:space:]]/, "")
end
# A single normalized column value from any mix of code strings/lists.
diff --git a/app/models/organization.rb b/app/models/organization.rb
index 91af0f0cd4..a507aa8dae 100644
--- a/app/models/organization.rb
+++ b/app/models/organization.rb
@@ -2,6 +2,7 @@ class Organization < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable # Publishable
include Communicable
include FilemakerCoded
+ filemaker_archived_in FmProject, FmOrganization
belongs_to :organization_status
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true
diff --git a/app/models/person.rb b/app/models/person.rb
index 5238306898..b68727f138 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -2,6 +2,7 @@ class Person < ApplicationRecord
include RemoteSearchable, TagFilterable, Trendable, WindowsTypeFilterable, SectorsTaggable, AgeGroupTaggable, StaffTaggable
include Communicable
include FilemakerCoded
+ filemaker_archived_in FmRolodex
pay_customer default_payment_processor: :stripe
diff --git a/app/views/fm_archives/show.html.erb b/app/views/fm_archives/show.html.erb
index 57b0911b36..e45d91251d 100644
--- a/app/views/fm_archives/show.html.erb
+++ b/app/views/fm_archives/show.html.erb
@@ -9,6 +9,17 @@
<%= link_to "Back to list", fm_archives_path(table: @record.class.table_name.sub("fm_", "")), class: button_classes(:primary_outline) %>
+ <% if @portal_records.any? %>
+
+
In the portal
+
+ <% @portal_records.each do |record| %>
+ <%= filemaker_portal_link(record) %>
+ <% end %>
+
+
+ <% end %>
+
<% @record.data.each do |key, value| %>
diff --git a/app/views/organizations/_form.html.erb b/app/views/organizations/_form.html.erb
index 7d9c15319b..572f087472 100644
--- a/app/views/organizations/_form.html.erb
+++ b/app/views/organizations/_form.html.erb
@@ -179,16 +179,11 @@
<% if allowed_to?(:manage?, Organization) %>
<%= f.input :filemaker_code,
- label: "FileMaker Project ID",
+ label: "FileMaker project or organization ID",
+ hint: filemaker_code_hint(f.object),
input_html: {
class: "rounded-md border-gray-300 bg-blue-100 shadow-sm focus:ring-blue-500 focus:border-blue-500"
} %>
- <%- if params[:admin] == "true" %>
-
- <%= filemaker_code_links(f.object, archive: FmProject, table: "projects", label: "View archive",
- link_class: "text-blue-600 hover:text-blue-800 underline") %>
-
- <% end %>
<% high_profile_gem = tag.i(class: "fa-solid fa-gem text-purple-600", aria: { hidden: true }) %>
<%= f.input :high_profile,
label: "High profile",
diff --git a/app/views/organizations/edit.html.erb b/app/views/organizations/edit.html.erb
index 31a36d4c4b..7d73eac082 100644
--- a/app/views/organizations/edit.html.erb
+++ b/app/views/organizations/edit.html.erb
@@ -9,10 +9,10 @@
<% end %>
<% end %>
<% actions = capture do %>
- <% if params[:admin] == "true" %>
- <%= filemaker_code_links(@organization, archive: FmProject, table: "projects", label: "FM project",
- link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %>
- <% end %>
+ <%= filemaker_project_links(@organization, label: "FM project", linked_only: true,
+ link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %>
+ <%= filemaker_organization_links(@organization, label: "FM organization", linked_only: true,
+ link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %>
<%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
<%= link_to "Organizations", organizations_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
<%= link_to "Profile", organization_path(@organization), class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
diff --git a/app/views/people/_admin_details.html.erb b/app/views/people/_admin_details.html.erb
index 959aebb3c8..8a4b71a944 100644
--- a/app/views/people/_admin_details.html.erb
+++ b/app/views/people/_admin_details.html.erb
@@ -98,8 +98,7 @@
- FileMaker code
-
- <%= filemaker_code_links(person, archive: FmRolodex, table: "rolodexes",
- link_class: "text-blue-600 underline hover:text-blue-800") || "β" %>
+ <%= filemaker_code_links(person, link_class: "text-blue-600 underline hover:text-blue-800") || "β" %>
- Racial/ethnic identity
- <%= person.racial_ethnic_identity.presence || "β" %>
diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb
index a2f373c923..0b8e97086d 100644
--- a/app/views/people/_form.html.erb
+++ b/app/views/people/_form.html.erb
@@ -467,14 +467,9 @@
<%= f.input :filemaker_code,
label: "FileMaker code",
+ hint: filemaker_code_hint(f.object),
input_html: { class: "w-full" },
wrapper_html: { class: "w-full" } %>
- <%- if params[:admin] == "true" %>
-
- <%= filemaker_code_links(f.object, archive: FmRolodex, table: "rolodexes", label: "View archive",
- link_class: "text-blue-600 hover:text-blue-800 underline") %>
-
- <% end %>
diff --git a/app/views/people/edit.html.erb b/app/views/people/edit.html.erb
index 0120c27367..0715d3a882 100644
--- a/app/views/people/edit.html.erb
+++ b/app/views/people/edit.html.erb
@@ -32,13 +32,11 @@
<% if @person.user %>
<%= link_to "User account", user_path(@person.user), class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %>
<% end %>
+ <%= filemaker_code_links(@person, label: "Rolodex", linked_only: true,
+ link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %>
<% if allowed_to?(:index?, with: Admin::AhoyActivityPolicy) %>
<%= link_to "Activity", admin_activities_events_path(person_id: @person.id, time_period: "all_time", audience: %w[visitors users staff], hide_account: "1", hide_interactions: "1"), class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1" %>
<% end %>
- <% if params[:admin] == "true" %>
- <%= filemaker_code_links(@person, archive: FmRolodex, table: "rolodexes", label: "Rolodex",
- link_class: "admin-only bg-blue-100 text-sm #{eyebrow_link_class} px-2 py-1") %>
- <% end %>
<%= link_to "Change password", change_password_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
<%= link_to "Home", root_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
<%= link_to "People", people_path, class: "text-sm #{eyebrow_link_class} px-2 py-1" %>
diff --git a/config/features.yml b/config/features.yml
index bcfbf12471..86b08a0409 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -4221,3 +4221,18 @@
The FileMaker archive now sorts its 44 tables into labeled groups β people,
organizations, events, payments, funding and more β so the table you want
is easier to find.
+
+- name: "Jump between people, orgs, and the FileMaker archive"
+ area: people
+ display_status: admin_facing
+ released_on: 2026-10-12
+ pr_number: 2648
+ summary: >-
+ Each FileMaker code on person and organization edit pages links to its
+ archive record, and each archive record links back to the portal person or
+ organization that carries its code.
+ pro_tips:
+ - "A struck-through code means the archive has no record for that code β check it for a typo."
+ - "Separate several codes with commas. Spaces, tabs, and line breaks are ignored, and the list saves sorted."
+ - "An organization's edit page also links the FileMaker organization its project belongs to."
+ - "An organization's code can be a FileMaker project ID (like P2775) or organization ID (like ORG07206). Type the whole code, letters included."
diff --git a/docs/adr/0005-filemaker-code-archive-mapping.md b/docs/adr/0005-filemaker-code-archive-mapping.md
index 934aeebddc..55643d059c 100644
--- a/docs/adr/0005-filemaker-code-archive-mapping.md
+++ b/docs/adr/0005-filemaker-code-archive-mapping.md
@@ -24,22 +24,155 @@ drive the archive pages' links.
| App column | Archive table | Archive key | Read by |
|---|---|---|---|
| `people.filemaker_code` | `fm_rolodexes` | `ID`, e.g. `00006` | `filemaker_code_links` on person edit + admin details |
-| `organizations.filemaker_code` | `fm_projects` | `ProjectID`, e.g. `P2775` | `filemaker_code_links` on org edit + form |
-| `affiliations.filemaker_code` | `fm_personnels` | `PrsnlRecID`, e.g. `PS9999` | dev seed (no UI link yet) |
+| `organizations.filemaker_code` | `fm_projects`, then `fm_organizations` | `ProjectID`, e.g. `P2775`; `OrgID`, e.g. `ORG07206` | `filemaker_code_links` on org form; `filemaker_project_links` / `filemaker_organization_links` on org edit |
+| `affiliations.filemaker_code` | `fm_projects`, then `fm_personnels` | `ProjectID`, e.g. `P2775`; rarely `PrsnlRecID`, e.g. `PS9999` | no UI link yet |
| `payments.filemaker_code` | `fm_payments` | `RecordID`, e.g. `D29186` | dev seed (no UI link yet) |
| `workshops.filemaker_code` | `fm_workshops` | `WorkshopID` | dev seed (no UI link yet) |
-| `resources.filemaker_code` | none | β | unmapped |
+| `resources.filemaker_code` | `fm_quotations` | `RecordID`, e.g. `Q10215` | no UI link yet |
-- **Organization β Project, not β Organization.** An app `Organization` is one art
- program site, which is a FileMaker *Project*. FileMaker's own Organization row is
- the umbrella above it, and maps to the parent in the nested-org tree (ADR-0004).
-- **Affiliation β Personnel.** Personnel is a person on a project with a role and
- agreement status. With orgs = projects, that's an affiliation.
-- **Resource has no counterpart.** No archive table holds resources. Form Letters
- are mail-merge templates, not resources, and nothing in the app maps to them. The
- seed leaves `resources.filemaker_code` blank.
+- **Organization β Project, or β Organization.** An app `Organization` is usually one
+ art program site, which is a FileMaker *Project*. FileMaker's own Organization row is
+ the umbrella above it, and maps to the parent in the nested-org tree (ADR-0004); an
+ app org can carry that `ORGβ¦` code directly too. Links run both ways through
+ `Project.OrgID`: the org edit page links the FM organization each of its projects
+ sits under, and an FM organization's archive page lists the orgs carrying its code
+ or one of its projects' codes.
+- **Codes match in full, never by number alone.** `P00023` and `ORG00023` are different
+ records, and a bare `00023` matches neither. The prefix is part of the id, and
+ prefixes aren't unique to one table (see below), so nothing strips or routes by it.
+- **Affiliation β Project.** Production affiliations carry their org's project id,
+ not a Personnel id. Of 1,531 `P` + 4-digit codes, all 1,531 are real projects and
+ 1,518 equal the affiliation's own org code. Reading them as Personnel ids missing the
+ `S` matched the affiliation's person once and its org never; as participant ids,
+ never its org. One affiliation carries a real `PS` Personnel id, so Personnel stays
+ as the second table. The 13 that differ from the org's code are worth a look: the
+ affiliation may sit on the wrong org, or the org may be missing that code.
+- **Resource β Quotation.** In production, 116 of 128 resource codes are quotation ids
+ (`Qβ¦`), 115 of them on Leader spotlight resources and one on a Story; 115 of the 116
+ exist in `fm_quotations` (quotes and testimonials, with a `WorkShopLeader` field).
+ Form Letters are mail-merge templates, not resources. The dev seed still leaves
+ `resources.filemaker_code` blank.
+
+## Archive id patterns
+
+What each archive table's `fm_id` looks like in production, profiled 2026-10-12 with a
+digits-as-`9` shape query over every archive table. Every table came back with a
+single prefix and fixed-width digits unless noted. Re-run it and update this grid
+when a new export lands.
+
+| Archive table | FileMaker key | Id pattern | Range in prod | Rows | Portal column |
+|---|---|---|---|---|---|
+| `fm_activities` | `ActivityLogID` | 5 digits | `00011`β`30061` | 29,185 | |
+| `fm_addresses` | `AddrsID` | 1β5 digits, unpadded | `1`β`30808` | 28,556 | |
+| `fm_allocations` | `AllocRecID` | 4 digits | `0001`β`3508` | 3,098 | |
+| `fm_ancillary_data` | `RecordID` | 1β3 digits, unpadded | `1`β`559` | 556 | |
+| `fm_art_purchases` | `PurchaseID` | 4 digits | `0001`β`0226` | 226 | |
+| `fm_art_registries` | `ItemID` | `A` + 4 digits | `A0001`β`A4405` | 3,902 | |
+| `fm_campaigns` | `CampaignID` | `CMP` + 3 digits | `CMP002`β`CMP011` | 10 | |
+| `fm_committee_meetings` | `CtMtgID` | `CMT` + 4 digits | `CMT0001` | 1 | |
+| `fm_committee_members` | `CMRecID` | `CM` + 4 digits | `CM0001`β`CM0741` | 689 | |
+| `fm_committees` | `CommitteeID` | `CT` + 3 digits | `CT001`β`CT060` | 28 | |
+| `fm_contacts` | `ContactID` | 5 digits | `00030`β`35548` | 248 | |
+| `fm_donor_developers` | `DonorDevRecID` | `SLR` + 4 digits | `SLR0001`β`SLR0172` | 164 | |
+| `fm_email_addresses` | `EmailAddrsID` | 1β2 digits, unpadded | `1`β`29` | 29 | |
+| `fm_events` | `EventID` | `E` + 4 digits | `E0002`β`E4048` | 3,478 | |
+| `fm_exhibited_items` | `RecordID` | `X` + 5 digits | `X00002`β`X03154` | 2,693 | |
+| `fm_expenditures` | `ExpendRecID` | `X` + 4 digits | `X0001`β`X5510` | 5,314 | |
+| `fm_form_fields` | `FieldNo` | 1β3 digits, optional `.` + 1β2 digits | `1.3`β`125.4` | 318 | |
+| `fm_form_letters` | `DocID` | `D` + 3 digits | `D064`β`D658` | 272 | |
+| `fm_form_submissions` | `RecordID` | `FSB` + 5 digits | `FSB00001`β`FSB03265` | 3,265 | |
+| `fm_forms_lists` | `FormID` | 2β3 digits, unpadded | `48`β`307` | 9 | |
+| `fm_fundings` | `RecordID` | `F` + 4 digits | `F0004`β`F1581` | 1,391 | |
+| `fm_group_members` | `RecordID` | `GM` + 5 digits | `GM00001`β`GM00017` | 16 | |
+| `fm_groups` | `GroupID` | `GP` + 5 digits | `GP00004`β`GP00273` | 245 | |
+| `fm_invoices` | `RecordID` | `INV` + 5 digits | `INV00003`β`INV02247` | 2,064 | |
+| `fm_line_items` | `ItemID` | `LIT` + 5 digits | `LIT01001`β`LIT01103` | 103 | |
+| `fm_match_donations` | `RecordID` | `MDN` + 4 digits | `MDN0001`β`MDN3454` | 3,250 | |
+| `fm_notes` | `NoteID` | `N` + 5 digits | `N00007`β`N00643` | 616 | |
+| `fm_organizations` | `OrgID` | `ORG` + 5 digits | `ORG00001`β`ORG07256` | 7,240 | `organizations` |
+| `fm_participants` | `PRecID` | `P` + 4β5 digits | `P0001`β`P79284` | 68,850 | |
+| `fm_payments` | `RecordID` | `D` + 5 digits | `D00001`β`D29263` | 27,220 | `payments` |
+| `fm_people_to_organizations` | `RecordID` | 1β4 digits, unpadded | `1`β`1128` | 1,128 | |
+| `fm_personnels` | `PrsnlRecID` | `PS` + 4β5 digits | `PS0028`β`PS10667` | 9,037 | `affiliations` |
+| `fm_phone_numbers` | `PhoneID` | 2β5 digits, unpadded | `22`β`35946` | 34,040 | |
+| `fm_postal_codes` | `Zipcode` | 5-digit ZIP | `00501`β`99950` | 41,685 | |
+| `fm_program_sponsorships` | `RecordID` | `PSP` + 4 digits | `PSP0035`β`PSP0901` | 608 | |
+| `fm_projects` | `ProjectID` | `P` + 4 digits | `P0001`β`P2806` | 2,007 | `organizations` |
+| `fm_quotations` | `RecordID` | `Q` + 4β5 digits | `Q0880`β`Q65300` | 62,611 | |
+| `fm_reportings` | `ReportID` | 5 digits | `00001`β`00791` | 727 | |
+| `fm_rolodexes` | `ID` | 5 digits, zero-padded | `00006`β`43871` | 39,795 | `people` |
+| `fm_services` | `RecordID` | 5 digits | `00001`β`73063` | 69,590 | |
+| `fm_solicitations` | `SolicitationID` | `SLN` + 5 digits | `SLN00001`β`SLN12043` | 11,815 | |
+| `fm_volunteers` | `VolID` | 5 digits | `00001`β`37788` | 2,845 | |
+| `fm_workshop_logs` | `RecordID` | 5 digits | `45920`β`56833` | 10,859 | |
+| `fm_workshops` | `WorkshopID` | `W` + 4 digits | `W0002`β`W0830` | 713 | `workshops` |
+
+### Where ids collide across tables
+
+The same string can be a valid id in more than one table, so a code is only ever
+looked up, in full, in the tables its portal column declares β never routed by prefix
+or shape.
+
+- **`P` + 4 digits is both a project and a participant.** Projects run `P0001`β`P2806`;
+ participants run `P0001`β`P79284`, so every project id is also a participant id.
+- **`X`:** exhibited items are `X` + 5 digits, expenditures `X` + 4 digits.
+- **`D`:** form letters are `D` + 3 digits, payments `D` + 5 digits.
+- **Nested prefixes:** `P` / `PS` / `PSP`, `CM` / `CMP` / `CMT`, and `SLR` / `SLN`.
+- **Bare 5-digit numbers** are Rolodex ids and also activities, contacts, services,
+ volunteers, reportings, workshop logs, and ZIP codes, with overlapping ranges.
+- **Unpadded numbers** (addresses, phones, ancillary data, people-to-organizations,
+ email addresses, forms lists) overlap each other from `1` up.
+
+## What portal records store
+
+Shapes of the codes on portal records in production, profiled 2026-10-12 (comma lists
+split into single codes). Compare against the grid above.
+
+| Portal column | Codes | Matches its mapping | Outliers |
+|---|---|---|---|
+| `organizations` | 1,987 | 1,965 `P` + 4 digits (projects) | 22: unpadded `P622`; no prefix `627`, `628`, `2781`, `9530`; letter O for zero `PO296`; extra digit `P03484`; `PP256`; placeholder `F0000` Γ2; bare 5-digit numbers Γ9 (Rolodex-shaped); 2 unreadable values |
+| `people` | 1 | 1 Rolodex-shaped (`39853`) | β the column is effectively unused |
+| `affiliations` | 1,535 | 1,531 `P` + 4 digits (projects; 1,518 equal the org's code), 1 `PS` (personnel) | `ORG05589`, `PP208`, `09856` |
+| `payments` | 0 | β | β |
+| `workshops` | 850 | 849 `W` + 4 digits | `XVI/4` |
+| `resources` | 128 | 115 `Q` + 4β5 digits found in quotations (114 Leader spotlight, 1 Story) | 1 `Q` id not in the archive; 10 bare 2β3 digit numbers, `v`, and a URL, all on Leader spotlights |
+
+Outliers get fixed by hand, not backfilled. Those codes already show struck through
+in the code field's hint.
+
+### Records to fix by hand
+
+Ids only, by issue. Nothing here names a record or repeats its code; open the record by
+id to see the code. Look the right id up in the archive, then fix the record's
+FileMaker code field.
+
+| Issue | Fix |
+|---|---|
+| Well-formed code with no archive record | Likely a digit typo. Find the record in the archive by name and replace the code, or clear it if FileMaker has no such record. |
+| Number without its prefix, or the wrong kind of id | Add the missing prefix and padding (e.g. `P` + 4 digits for a project), or, if it's another table's id (a 5-digit Rolodex id on an org), move it to the right record and clear it here. |
+| Malformed code | Fix the letters (a letter `O` for a zero, a doubled prefix), drop extra digits, or clear placeholders and pasted text. An `ORGβ¦` code on an affiliation belongs on its org instead. |
+| Unreadable value | The stored value has characters that can't be split into codes. Retype the code. |
+| Project code differs from its org's code (affiliations) | Either the affiliation is on the wrong org (move it), or the org is missing that project code (add it to the org). |
+
+Production, 2026-10-12: 84 records (41 organizations, 16 affiliations, 14 workshops,
+13 resources, no people). A record listed under two issues has two bad codes.
+
+| Portal table | Issue | Count | Record ids |
+|---|---|---|---|
+| `organizations` | Well-formed code with no archive record | 19 | 66, 247, 270, 283, 297, 365, 425, 437, 443, 499, 665, 668, 670, 814, 973, 1046, 1091, 1107, 1398 |
+| `organizations` | Number without its prefix, or the wrong kind of id | 14 | 233, 364, 388, 439, 593, 614, 640, 641, 822, 838, 865, 877, 978, 2043 |
+| `organizations` | Malformed code | 8 | 108, 136, 241, 941, 1088, 1089, 1550, 1891 |
+| `affiliations` | Number without its prefix, or the wrong kind of id | 1 | 4424 |
+| `affiliations` | Malformed code | 2 | 5236, 6385 |
+| `affiliations` | Project code differs from its org's code | 13 | 2656, 3649, 3730, 5042, 5043, 5164, 5461, 5857, 5920, 6178, 6234, 6284, 6387 |
+| `workshops` | Well-formed code with no archive record | 13 | 215, 294, 357, 501, 708, 756, 797, 865, 926, 954, 982, 1010, 6302 |
+| `workshops` | Malformed code | 1 | 521 |
+| `resources` | Well-formed code with no archive record | 1 | 624 |
+| `resources` | Number without its prefix, or the wrong kind of id | 10 | 572, 573, 574, 575, 588, 592, 593, 594, 595, 596 |
+| `resources` | Malformed code | 2 | 550, 580 |
## Consequences
-A UI link for affiliations, payments, or workshops passes the archive table above to
-`filemaker_code_links` (e.g. `archive: FmPersonnel, table: "personnels"`).
+A UI link for affiliations, payments, workshops, or resources includes `FilemakerCoded` on the
+model and declares the archive table above (e.g. `filemaker_archived_in FmPersonnel`);
+`filemaker_code_links` reads it from there.
diff --git a/spec/helpers/filemaker_helper_spec.rb b/spec/helpers/filemaker_helper_spec.rb
new file mode 100644
index 0000000000..4485c1a52b
--- /dev/null
+++ b/spec/helpers/filemaker_helper_spec.rb
@@ -0,0 +1,151 @@
+require "rails_helper"
+
+RSpec.describe FilemakerHelper, type: :helper do
+ let(:archive_viewer) { true }
+
+ before do
+ allow(helper).to receive(:allowed_to?).with(:show?, :fm_archive).and_return(archive_viewer)
+ end
+
+ describe "#filemaker_code_links" do
+ let(:person) { build(:person, filemaker_code: "00006, 00012") }
+
+ before { FmRolodex.create!(fm_id: "00006") }
+
+ it "links each code the archive holds to its archive table" do
+ html = helper.filemaker_code_links(person, label: "Rolodex")
+
+ expect(html).to include(%(href="#{fm_archive_path("00006", table: "rolodexes")}"))
+ expect(html).to include("Rolodex 00006")
+ end
+
+ it "shows just the label when the record carries a single code" do
+ html = helper.filemaker_code_links(build(:person, filemaker_code: "00006"), label: "Rolodex")
+
+ expect(html).to include(">Rolodex")
+ expect(html).not_to include("Rolodex 00006")
+ end
+
+ it "marks a code the archive has no row for instead of linking it" do
+ html = helper.filemaker_code_links(person)
+
+ expect(html).not_to include(fm_archive_path("00012", table: "rolodexes"))
+ expect(html).to include(%(title="No FileMaker archive record for 00012"))
+ end
+
+ it "returns nothing for a record without codes" do
+ expect(helper.filemaker_code_links(build(:person, filemaker_code: nil))).to be_nil
+ end
+
+ context "when only linked codes are wanted" do
+ it "drops a code the archive has no row for" do
+ html = helper.filemaker_code_links(person, label: "Rolodex", linked_only: true)
+
+ expect(html).not_to include("00012")
+ expect(html).not_to include("No FileMaker archive record")
+ end
+
+ it "names the one remaining link by its label alone" do
+ html = helper.filemaker_code_links(person, label: "Rolodex", linked_only: true)
+
+ expect(html).to include(">Rolodex")
+ expect(html).not_to include("Rolodex 00006")
+ end
+
+ it "returns nothing when no code has an archive row" do
+ html = helper.filemaker_code_links(build(:person, filemaker_code: "00012"), label: "Rolodex", linked_only: true)
+
+ expect(html).to be_nil
+ end
+ end
+
+ context "when the viewer can't open the archive" do
+ let(:archive_viewer) { false }
+
+ it "renders nothing" do
+ expect(helper.filemaker_code_links(person)).to be_nil
+ end
+ end
+ end
+
+ describe "#filemaker_code_hint" do
+ before { FmRolodex.create!(fm_id: "00006") }
+
+ it "lists each code as an archive link" do
+ html = helper.filemaker_code_hint(build(:person, filemaker_code: "00006"))
+
+ expect(html).to include("In the archive:")
+ expect(html).to include(%(href="#{fm_archive_path("00006", table: "rolodexes")}"))
+ expect(html).to include("00006")
+ end
+
+ it "returns nothing for a record without codes" do
+ expect(helper.filemaker_code_hint(build(:person, filemaker_code: nil))).to be_nil
+ end
+ end
+
+ describe "#filemaker_organization_links" do
+ let(:organization) { build(:organization, filemaker_code: "P2775") }
+
+ before do
+ FmProject.create!(fm_id: "P2775", data: { "OrgID" => "ORG07206" })
+ FmOrganization.create!(fm_id: "ORG07206")
+ end
+
+ it "links the FM organization each of the org's projects belongs to" do
+ html = helper.filemaker_organization_links(organization, label: "FM organization")
+
+ expect(html).to include(fm_archive_path("ORG07206", table: "organizations"))
+ expect(html).to include(">FM organization")
+ end
+
+ it "drops an FM organization the archive has no row for when only linked ones are wanted" do
+ FmProject.create!(fm_id: "P9000", data: { "OrgID" => "ORG99999" })
+
+ html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P9000"), label: "FM organization", linked_only: true)
+
+ expect(html).to be_nil
+ end
+
+ it "links an FM organization the org carries directly, alongside its projects' FM organizations" do
+ FmOrganization.create!(fm_id: "ORG00023")
+
+ html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P2775, ORG00023"), label: "FM organization")
+
+ expect(html).to include(fm_archive_path("ORG00023", table: "organizations"))
+ expect(html).to include(fm_archive_path("ORG07206", table: "organizations"))
+ end
+
+ it "links the FM organization once when the org carries it and one of its projects sits under it" do
+ html = helper.filemaker_organization_links(build(:organization, filemaker_code: "P2775, ORG07206"), label: "FM organization")
+
+ expect(html.scan(fm_archive_path("ORG07206", table: "organizations")).size).to eq(1)
+ end
+ end
+
+ describe "#filemaker_project_links" do
+ it "links only the org's FM project codes" do
+ FmProject.create!(fm_id: "P2775")
+ FmOrganization.create!(fm_id: "ORG00023")
+
+ html = helper.filemaker_project_links(build(:organization, filemaker_code: "P2775, ORG00023"), label: "FM project", linked_only: true)
+
+ expect(html).to include(fm_archive_path("P2775", table: "projects"))
+ expect(html).not_to include("ORG00023")
+ expect(html).to include(">FM project")
+ end
+ end
+
+ describe "organization codes" do
+ it "match the full code, so a bare number reaches neither a project nor an FM organization" do
+ FmProject.create!(fm_id: "P00023")
+ FmOrganization.create!(fm_id: "ORG00023")
+
+ html = helper.filemaker_code_links(build(:organization, filemaker_code: "00023, P00023, ORG00023"))
+
+ expect(html).to include(fm_archive_path("P00023", table: "projects"))
+ expect(html).to include(fm_archive_path("ORG00023", table: "organizations"))
+ expect(html).to include(%(title="No FileMaker archive record for 00023"))
+ end
+ end
+end
diff --git a/spec/models/concerns/filemaker_coded_spec.rb b/spec/models/concerns/filemaker_coded_spec.rb
new file mode 100644
index 0000000000..6098393b91
--- /dev/null
+++ b/spec/models/concerns/filemaker_coded_spec.rb
@@ -0,0 +1,70 @@
+require "rails_helper"
+
+RSpec.describe FilemakerCoded do
+ describe ".with_filemaker_code" do
+ it "matches a code anywhere in the list, however it's spaced" do
+ single = create(:person, filemaker_code: "00006")
+ listed = create(:person, filemaker_code: "00012,00006")
+ spaced = create(:person, filemaker_code: "00012 , 00006")
+ create(:person, filemaker_code: "000066")
+ create(:person, filemaker_code: nil)
+
+ expect(Person.with_filemaker_code(" 00006 ")).to contain_exactly(single, listed, spaced)
+ end
+
+ it "ignores tabs, line breaks, and non-breaking spaces stored in the list" do
+ messy = create(:person)
+ messy.update_columns(filemaker_code: "00012,\t00006\u00A0\n")
+
+ expect(Person.with_filemaker_code("00006")).to contain_exactly(messy)
+ end
+ end
+
+ describe ".with_any_filemaker_code" do
+ it "matches records carrying any of the codes" do
+ first = create(:person, filemaker_code: "00006")
+ second = create(:person, filemaker_code: "00012, 00099")
+ create(:person, filemaker_code: "00013")
+
+ expect(Person.with_any_filemaker_code(%w[00006 00012])).to contain_exactly(first, second)
+ end
+
+ it "matches nothing for no codes" do
+ create(:person, filemaker_code: "00006")
+
+ expect(Person.with_any_filemaker_code([])).to be_empty
+ end
+ end
+
+ describe "#filemaker_codes" do
+ it "splits on commas only, dropping every kind of whitespace" do
+ person = build(:person)
+ person.filemaker_code = " 00006\t,\n00012\u00A0, ,00006"
+
+ expect(person.filemaker_codes).to eq(%w[00006 00012])
+ end
+
+ it "keeps a list joined by another separator as one code" do
+ expect(build(:person, filemaker_code: "00006; 00012").filemaker_codes).to eq([ "00006;00012" ])
+ end
+ end
+
+ describe "saving" do
+ it "stores the codes as a sorted, comma-space list" do
+ person = create(:person, filemaker_code: "00012 ,\t00006,00012\n")
+
+ expect(person.reload.filemaker_code).to eq("00006, 00012")
+ end
+
+ it "stores nil for a blank list" do
+ expect(create(:person, filemaker_code: " , \t").reload.filemaker_code).to be_nil
+ end
+ end
+
+ describe ".filemaker_archives" do
+ it "names the archive tables each coded model points into" do
+ expect(Person.filemaker_archives).to eq([ FmRolodex ])
+ expect(Organization.filemaker_archives).to eq([ FmProject, FmOrganization ])
+ end
+ end
+end
diff --git a/spec/requests/fm_archives_spec.rb b/spec/requests/fm_archives_spec.rb
index cc12f19b66..e4e16165e5 100644
--- a/spec/requests/fm_archives_spec.rb
+++ b/spec/requests/fm_archives_spec.rb
@@ -3,22 +3,93 @@
RSpec.describe "FM archives", type: :request do
let(:admin) { create(:user, :admin) }
- before { sign_in admin }
-
describe "GET /fm_archives/:id" do
- let!(:rolodex) { FmRolodex.create!(fm_id: "ROL1", data: { "ID" => "ROL1" }) }
- let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1", "FunderID" => "ROL1" }) }
+ before { sign_in admin }
+
+ context "with related records" do
+ let!(:rolodex) { FmRolodex.create!(fm_id: "ROL1", data: { "ID" => "ROL1" }) }
+ let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1", "FunderID" => "ROL1" }) }
+
+ it "lists a rolodex record's funding under related records" do
+ get fm_archive_path(rolodex.fm_id, table: "rolodexes")
+
+ expect(response.body).to include("Funding (1)", "table=fundings")
+ end
+ end
+
+ it "links back to the portal people carrying the archive code" do
+ FmRolodex.create!(fm_id: "00006")
+ person = create(:person, first_name: "Archie", last_name: "Coded", filemaker_code: "00012, 00006")
+ create(:person, filemaker_code: "000066")
+
+ get fm_archive_path("00006", table: "rolodexes")
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(person_path(person))
+ expect(response.body).to include("Archie Coded")
+ expect(response.body.scan(%r{href="/people/\d+"}).size).to eq(1)
+ end
+
+ it "links an archived project back to its portal organization" do
+ FmProject.create!(fm_id: "P2775")
+ organization = create(:organization, filemaker_code: "P2775")
+
+ get fm_archive_path("P2775", table: "projects")
+
+ expect(response.body).to include(organization_path(organization))
+ end
- it "lists a rolodex record's funding under related records" do
- get fm_archive_path(rolodex.fm_id, table: "rolodexes")
+ it "links an archived FM organization back to the portal organization carrying its code" do
+ FmOrganization.create!(fm_id: "ORG00023")
+ organization = create(:organization, filemaker_code: "ORG00023")
+ create(:organization, filemaker_code: "P00023")
- expect(response.body).to include("Funding (1)", "table=fundings")
+ get fm_archive_path("ORG00023", table: "organizations")
+
+ expect(response.body).to include(organization_path(organization))
+ expect(response.body.scan(%r{href="/organizations/\d+"}).size).to eq(1)
+ end
+
+ it "links an archived FM organization back to the portal organizations its projects belong to" do
+ FmOrganization.create!(fm_id: "ORG07206")
+ FmProject.create!(fm_id: "P2775", data: { "OrgID" => "ORG07206" })
+ FmProject.create!(fm_id: "P2776", data: { "OrgID" => "ORG07206" })
+ FmProject.create!(fm_id: "P9000", data: { "OrgID" => "ORG09999" })
+ via_project = create(:organization, filemaker_code: "P2775")
+ via_both = create(:organization, filemaker_code: "P2776, ORG07206")
+ create(:organization, filemaker_code: "P9000")
+
+ get fm_archive_path("ORG07206", table: "organizations")
+
+ expect(response.body).to include(organization_path(via_project), organization_path(via_both))
+ expect(response.body.scan(%r{href="/organizations/\d+"}).size).to eq(2)
+ end
+
+ it "omits the portal section when nothing carries the code" do
+ FmRolodex.create!(fm_id: "00006")
+
+ get fm_archive_path("00006", table: "rolodexes")
+
+ expect(response.body).not_to include("In the portal")
+ end
+ end
+
+ describe "GET /fm_archives/:id as a non-admin" do
+ it "is not allowed" do
+ FmRolodex.create!(fm_id: "00006")
+ sign_in create(:user)
+
+ get fm_archive_path("00006", table: "rolodexes")
+
+ expect(response).to redirect_to(root_path)
end
end
describe "GET /fm_archives" do
let!(:funding) { FmFunding.create!(fm_id: "FND1", data: { "RecordID" => "FND1" }) }
+ before { sign_in admin }
+
it "lists funding records at the table name a funding record's back link uses" do
get fm_archives_path(table: FmFunding.table_name.delete_prefix("fm_"))