Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
800fa96
Start the FileMaker organizations and affiliations import
maebeale Oct 22, 2026
1bc94cd
Import FileMaker organizations, programs, and affiliations
maebeale Oct 22, 2026
ef9aea3
Record that every FileMaker organization comes over
maebeale Oct 24, 2026
9b83e7c
Read Personnel roles and org types as production records them
maebeale Oct 24, 2026
e58a3f9
Import a FileMaker Leader as a primary-contact Facilitator
maebeale Oct 24, 2026
f920871
Give FileMaker liaisons and assistants prod's January shape
maebeale Oct 24, 2026
29119ff
Import FileMaker liaisons as primary-contact facilitators, noting the…
maebeale Oct 24, 2026
704f9f8
Import every FileMaker Personnel role as a Facilitator
maebeale Oct 24, 2026
9bb8429
Keep a FileMaker program on record when its Personnel row names no one
maebeale Oct 24, 2026
95d48be
Note the AWBW Placeholder fallback in AGENTS.md
maebeale Oct 24, 2026
c1b5687
Mark primary contact only where FileMaker says so; match by stretch
maebeale Oct 24, 2026
fe2eb38
Expect affiliations to point into people-to-organizations too
maebeale Oct 24, 2026
1681659
Take address handling out of the organizations import; use the shared…
maebeale Oct 24, 2026
2f84dbc
Fold leftover Leader affiliations into Facilitator from Data health
maebeale Oct 25, 2026
36057c2
Review Leader affiliations row by row instead of one fold button
maebeale Oct 25, 2026
3044707
Only report Leader affiliations; leave the cleanup to person edit
maebeale Oct 25, 2026
517822a
Show whether a Leader row's person already facilitates there
maebeale Oct 25, 2026
4367378
Report every old-app role title in Data health, not just Leader
maebeale Oct 25, 2026
7c75752
Recount services and rake tasks after main dropped the address transl…
maebeale Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/models/affiliation.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class Affiliation < ApplicationRecord
include Communicable
include FilemakerCoded
filemaker_archived_in FmProject, FmPersonnel
filemaker_archived_in FmProject, FmPersonnel, FmPeopleToOrganization

# Standing title given to the "facilitator affiliation" we create on registration
# and org linking. Matches the `facilitators` scope / `facilitator?` predicate
Expand Down
15 changes: 15 additions & 0 deletions app/models/fm_rolodex.rb
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,21 @@ def addresses
@addresses ||= contact_records(FmAddress, primary_ids: address_ids)
end

# The row is an agency's own contact card, sometimes naming the person to reach there.
def agency?
FilemakerValues.flag?(data["isAgency__c"])
end

def work_address?(address)
address.fm_id == data["WorksiteAddrsID"] ||
FilemakerValues.contact_type(address.data["AddrsType"], allowed: Address::CONTACT_TYPES) == "work"
end

# Where the person works rather than lives β€” every address on an agency's card is the agency's.
def organization_address?(address)
agency? || work_address?(address)
end

def job_affiliations
@job_affiliations ||= FmPeopleToOrganization.find_by_data("PersonID", fm_id).order(:fm_id).to_a
end
Expand Down
386 changes: 386 additions & 0 deletions app/services/affiliation_services/import_from_filemaker.rb

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions app/services/affiliation_stint.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# One affiliation's stretch of time, for telling a person's separate stints at one
# organization apart. A blank start reads as "since whenever" and a blank end as "still
# going" β€” except an ended row with no end date, which reads as the moment it started, so
# it can't reach a later stretch.
AffiliationStint = Data.define(:starts, :ends) do
def self.for(start_date:, end_date:, ended:)
new(starts: start_date, ends: end_date || (start_date if ended))
end

def self.of(affiliation)
self.for(start_date: affiliation.start_date, end_date: affiliation.end_date, ended: affiliation.inactive?)
end

def overlap?(other)
(starts.nil? || other.ends.nil? || starts <= other.ends) && (other.starts.nil? || ends.nil? || other.starts <= ends)
end
end
1 change: 1 addition & 0 deletions app/services/data_health.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module DataHealth
"Affiliations" => [
FacilitatorAffiliationsFromNonTrainings,
MisalignedAffiliationProvenance,
LegacyRoleAffiliations,
LegacyOrganizationStatusDrift
],
"People" => [
Expand Down
62 changes: 62 additions & 0 deletions app/services/data_health/legacy_role_affiliations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
module DataHealth
# Affiliations still titled with one of the old app's roles, which the January 2026
# conversion copied into titles: a Leader or Liaison was a Facilitator who is a primary
# contact, an Assistant a Facilitator who isn't. None of those titles counts toward program
# status. Report-only: each row shows the person's Facilitator rows at that organization and
# opens their edit page to settle by hand.
class LegacyRoleAffiliations < Check
LEGACY_TITLES = %w[Leader Liaison Assistant default].freeze

def title = "Affiliations still titled with an old-app role"

def explanation
"The old app's Leader, Liaison and Assistant roles (and its blank \"default\") became titles in " \
"January, but only a Facilitator affiliation counts toward program status. Each row is listed " \
"with the person's Facilitator rows at that organization; open the person to merge, rename or remove it."
end

def scope
Affiliation.where(title: LEGACY_TITLES).includes(:person, :organization).order(:title, :organization_id, :person_id, :id)
end

def self.legacy_title?(title)
LEGACY_TITLES.any? { |legacy| legacy.casecmp?(title.to_s.squish) }
end

def row_partial = "admin/data_health/legacy_role_row"

def describe(affiliation)
"#{affiliation.person&.name} β€” #{affiliation.organization&.name} (#{affiliation.title})"
end

# The person's Facilitator rows at the affiliation's organization, oldest first.
def facilitators_for(affiliation)
Affiliation.facilitators.where(person_id: affiliation.person_id, organization_id: affiliation.organization_id)
.order(:start_date, :id).to_a
end

# How many organizations other than this one the person facilitates at.
def facilitator_elsewhere_count(affiliation)
Affiliation.facilitators.where(person_id: affiliation.person_id).where.not(organization_id: affiliation.organization_id)
.distinct.count(:organization_id)
end

# The Facilitator row recording the same stretch (AffiliationStint), the same dates first.
def twin_for(affiliation, facilitators = facilitators_for(affiliation))
stint = AffiliationStint.of(affiliation)
facilitators.select { |facilitator| AffiliationStint.of(facilitator).overlap?(stint) }
.min_by { |facilitator| same_dates?(facilitator, affiliation) ? 0 : 1 }
end

def stretch_label(facilitator, affiliation, twin)
return "same dates" if same_dates?(facilitator, affiliation)
return "overlaps with different dates" if facilitator == twin

"a different stretch"
end

def same_dates?(facilitator, affiliation)
facilitator.start_date == affiliation.start_date && facilitator.end_date == affiliation.end_date
end
end
end
30 changes: 30 additions & 0 deletions app/services/filemaker_code_index.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Which records of a FilemakerCoded model carry each code, read once so a bulk import
# looks codes up in memory rather than scanning the table per archive row. Records the
# import codes as it goes are added with #add, so a later row finds them.
class FilemakerCodeIndex
def initialize(model)
@model = model
@ids = Hash.new { |hash, code| hash[code] = [] }
model.where.not(filemaker_code: [ nil, "" ]).pluck(:id, :filemaker_code).each do |id, value|
model.split_filemaker_codes(value).each { |code| @ids[code] << id }
end
end

def ids(code)
@ids.fetch(@model.strip_filemaker_code(code), [])
end

def include?(code)
ids(code).any?
end

def records(code)
ids = ids(code)
ids.empty? ? [] : @model.where(id: ids).order(:id).to_a
end

def add(code, id)
code = @model.strip_filemaker_code(code)
@ids[code] |= [ id ] if code.present?
end
end
73 changes: 73 additions & 0 deletions app/services/filemaker_values.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# How every FileMaker archive import reads a raw field value: dates in the archive's mixed
# formats, yes/no flags, contact types, and free text bounded to the column it lands in.
# Included by the importers so each reads the archive the same way.
module FilemakerValues
module_function

STRING_LIMIT = 255
EMAIL_PATTERN = /[^\s,;<>"']+@[^\s,;<>"']+\.[^\s,;<>"']+/
FALSE_FLAGS = %w[0 n no false].freeze
DATE_FORMATS = %w[%m/%d/%Y %Y-%m-%d %m-%d-%Y %Y/%m/%d].freeze
# A two-digit year below this is read as 20xx, at or above it as 19xx.
TWO_DIGIT_YEAR_PIVOT = 10
CONTACT_TYPES = AddressTranslation::CONTACT_TYPES

# Every string copied here lands in a varchar(255) the archive's free text isn't
# bounded by, and an over-long incidental field would otherwise cost the record β€”
# or, for a column with no length validation behind it, raise mid-import.
def clip(value)
value.presence && value.to_s.truncate(STRING_LIMIT)
end

# The archive words an email field freely ("Attn: someone", a note, two addresses at
# once), so it's read for the addresses in it the portal's email validation accepts.
def email_addresses(value)
value.to_s.scan(EMAIL_PATTERN).select { |email| valid_email?(email) }
end

def valid_email?(email)
email.length <= STRING_LIMIT && email.match?(URI::MailTo::EMAIL_REGEXP)
end

def flag?(value)
value.to_s.strip.presence && !FALSE_FLAGS.include?(value.to_s.strip.downcase)
end

# The archive's types map onto one set of ours, but each model declares the slice it
# accepts β€” an address can be the mailing one, a phone number can't. A type we can't
# place is left blank, which both models then record as unknown.
def contact_type(value, allowed:)
CONTACT_TYPES[value.to_s.strip.downcase].presence_in(allowed)
end

def parse_date(value)
value = value.to_s.strip
return if value.blank?

DATE_FORMATS.each do |format|
parsed = parse_with(value, format)
return parsed if parsed
end
nil
end

# Parsing stops where the format ends, so "10-5-1983" read as %Y-%m-%d takes 19
# as the day and drops the 83 β€” digits left over mean the format split the value
# in the wrong place, so the next one gets its turn.
def parse_with(value, format)
parts = Date._strptime(value, format)
return if parts.nil? || parts[:leftover].to_s.match?(/\A\d/)

expand_century(Date.new(parts[:year], parts[:mon], parts[:mday]))
rescue Date::Error, TypeError
nil
end

# %Y reads a two-digit year as the year itself, so "10/5/83" is 83 AD. The archive
# mixes two- and four-digit years, so a bare pair gets a century: 83 is 1983, 05 is 2005.
def expand_century(date)
return date if date.year >= 100

date.change(year: date.year + (date.year < TWO_DIGIT_YEAR_PIVOT ? 2000 : 1900))
end
end
Loading
Loading