Mask contact data the sharing preferences form withholds

This commit is contained in:
Ian Gulliver
2026-08-20 22:43:34 -07:00
parent 5038829ac5
commit 99119dac53
10 changed files with 286 additions and 24 deletions
+176 -5
View File
@@ -8,12 +8,18 @@ import (
"regexp"
"sort"
"strings"
"time"
"heliosian/internal/data"
)
const appName = "directory"
const (
preferencesApp = "preferences"
preferencesTab = "Sheet1"
)
var gradeOrder = []string{
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4",
"Grade 5", "Grade 6", "Grade 7", "Grade 8",
@@ -39,9 +45,28 @@ var departmentOrder = []string{
}
var importColumns = []string{
"entry_sort_name", "student_full_name", "student_classifications", "student_email", "student_phone_mobile",
"entry_sort_name", "student_full_name", "student_classifications", "student_email",
}
// The permission column's header is misspelled in the form itself; it must match verbatim.
const (
preferenceTimestamp = "Timestamp"
preferenceEmail = "Email Address"
preferenceStatus = "Communication Opt-In Status"
preferencePermission = "You have my permission to share the folllowing:"
)
const (
optInAnswer = "I agree to have family names and emails in the Helios Community Apps"
optOutAnswer = "Please remove all family names and emails from the Helios Community Apps. " +
"I understand that we will be unable to access the Helios Who directory, " +
"the volunteer portal and Spring Celebration fun(d)raiser events."
sharePhone = "Adult Phone Number (if provided on Veracross)"
shareAddress = "Home Address (if provided on Veracross)"
)
const preferenceTimeFormat = "1/2/2006 15:04:05"
var overrideColumns = []string{
"Email", "Added", "Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
@@ -59,6 +84,8 @@ type parsedName struct {
var nameForm = regexp.MustCompile(`^(.+?) \((.+?)\) (.+)$`)
var emailForm = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
func parseName(raw string) parsedName {
raw = strings.Join(strings.Fields(raw), " ")
m := nameForm.FindStringSubmatch(raw)
@@ -92,6 +119,22 @@ type familyCells struct {
hasAddress, hasPhone, hasCaption bool
}
func exactColumns(table string, header, wanted []string) error {
if err := requireColumns(table, header, wanted); err != nil {
return err
}
known := map[string]bool{}
for _, w := range wanted {
known[w] = true
}
for _, h := range header {
if !known[h] {
return fmt.Errorf("table %s has unexpected column %q", table, h)
}
}
return nil
}
func requireColumns(table string, header, wanted []string) error {
present := map[string]bool{}
for _, h := range header {
@@ -116,9 +159,10 @@ type loader struct {
blobs BlobChecker
static BlobChecker
importRows []map[string]string
overrideRows []map[string]string
nameToEmail map[string]string
importRows []map[string]string
overrideRows []map[string]string
preferenceRows []map[string]string
nameToEmail map[string]string
people map[string]*Person
order []string
@@ -150,7 +194,9 @@ func LoadModel(source data.Source, blobs, static BlobChecker) (*Model, error) {
func() error { return l.readTables(source) },
l.transformImport,
l.applyOverrides,
l.hideStudentPhones,
l.buildFamilies,
l.applyPreferences,
l.removeOptedOut,
l.attachBlobs,
l.sortPeople,
@@ -189,6 +235,16 @@ func (l *loader) readTables(source data.Source) error {
return err
}
l.overrideRows = overrideRows
preferenceHeader, preferenceRows, err := source.Table(preferencesApp, preferencesTab)
if err != nil {
return err
}
if err := exactColumns(preferencesTab, preferenceHeader, []string{
preferenceTimestamp, preferenceEmail, preferenceStatus, preferencePermission,
}); err != nil {
return err
}
l.preferenceRows = preferenceRows
l.nameToEmail = map[string]string{}
for _, row := range mapRows {
@@ -264,7 +320,6 @@ func (l *loader) transformImport() error {
student := &Person{
Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred,
IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew,
Phone: row["student_phone_mobile"],
}
l.people[email] = student
l.order = append(l.order, email)
@@ -455,6 +510,15 @@ func (l *loader) applyOverrides() error {
return nil
}
func (l *loader) hideStudentPhones() error {
for _, p := range l.people {
if p.IsStudent {
p.Phone = ""
}
}
return nil
}
func (l *loader) buildFamilies() error {
for _, setKey := range l.householdOrder {
hh := l.households[setKey]
@@ -499,6 +563,113 @@ func (l *loader) buildFamilies() error {
return nil
}
type preference struct {
when time.Time
status OptStatus
address bool
phone bool
}
func parsePreference(row map[string]string) (string, preference, error) {
email := strings.ToLower(row[preferenceEmail])
if !emailForm.MatchString(email) {
return "", preference{}, fmt.Errorf("preferences row %v has invalid email %q", row, row[preferenceEmail])
}
when, err := time.Parse(preferenceTimeFormat, row[preferenceTimestamp])
if err != nil {
return "", preference{}, fmt.Errorf("preferences row %s has invalid timestamp %q", email, row[preferenceTimestamp])
}
pref := preference{when: when}
switch row[preferenceStatus] {
case optInAnswer:
pref.status = OptIn
case optOutAnswer:
pref.status = OptOut
default:
return "", preference{}, fmt.Errorf("preferences row %s has unknown opt-in status %q", email, row[preferenceStatus])
}
if cell := row[preferencePermission]; cell != "" {
for _, item := range strings.Split(cell, ", ") {
switch {
case item == shareAddress && !pref.address:
pref.address = true
case item == sharePhone && !pref.phone:
pref.phone = true
default:
return "", preference{}, fmt.Errorf("preferences row %s grants invalid permission %q", email, item)
}
}
}
return email, pref, nil
}
func (l *loader) applyPreferences() error {
byFamily := map[string]preference{}
byPerson := map[string]preference{}
for _, row := range l.preferenceRows {
email, pref, err := parsePreference(row)
if err != nil {
return err
}
if _, ok := l.people[email]; !ok {
continue
}
sets := l.personHouseholds[email]
if len(sets) == 0 {
if current, ok := byPerson[email]; !ok || pref.when.After(current.when) {
byPerson[email] = pref
}
continue
}
for _, set := range sets {
key := l.familyKeys[set]
if current, ok := byFamily[key]; !ok || pref.when.After(current.when) {
byFamily[key] = pref
}
}
}
for key, pref := range byFamily {
family := l.model.Families[key]
if !pref.address {
family.Address = ""
family.AddressMasked = true
}
if !pref.phone {
family.Phone = ""
family.PhoneMasked = true
}
l.model.Families[key] = family
}
for _, email := range l.order {
p := l.people[email]
p.OptStatus = OptDefault
governing := []preference{}
for _, set := range l.personHouseholds[email] {
if pref, ok := byFamily[l.familyKeys[set]]; ok {
governing = append(governing, pref)
}
}
if pref, ok := byPerson[email]; ok {
governing = append(governing, pref)
}
for _, pref := range governing {
if pref.status == OptOut || p.OptStatus == OptDefault {
p.OptStatus = pref.status
}
if !pref.address {
p.AddressMasked = true
}
if !pref.phone && !p.IsStudent {
p.PhoneMasked = true
p.Phone = ""
}
}
}
return nil
}
func (l *loader) removeOptedOut() error {
for email := range l.optedOut {
delete(l.people, email)
+14
View File
@@ -1,5 +1,13 @@
package directory
type OptStatus string
const (
OptDefault OptStatus = "default"
OptIn OptStatus = "in"
OptOut OptStatus = "out"
)
type Person struct {
Email string `json:"email"`
FullName string `json:"fullName"`
@@ -22,6 +30,10 @@ type Person struct {
JobTitle string `json:"jobTitle,omitempty"`
Department string `json:"department,omitempty"`
GradeBand string `json:"gradeBand,omitempty"`
OptStatus OptStatus `json:"optStatus"`
AddressMasked bool `json:"addressMasked,omitempty"`
PhoneMasked bool `json:"phoneMasked,omitempty"`
}
type Family struct {
@@ -36,6 +48,8 @@ type Family struct {
PronunciationURL string `json:"pronunciationUrl,omitempty"`
AdultEmails []string `json:"adultEmails,omitempty"`
KidEmails []string `json:"kidEmails,omitempty"`
AddressMasked bool `json:"addressMasked,omitempty"`
PhoneMasked bool `json:"phoneMasked,omitempty"`
}
type Classroom struct {