Add self-service person-level opt-out with full removal at load

This commit is contained in:
Ian Gulliver
2026-08-16 11:34:32 -07:00
parent 4d70d6c2a8
commit 4abe9d281f
7 changed files with 145 additions and 69 deletions
+48 -4
View File
@@ -47,7 +47,7 @@ var overrideColumns = []string{
"Email", "Added", "Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
}
type BlobChecker interface {
@@ -282,6 +282,7 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
bandSet[band] = true
}
roomParents := map[string][]string{}
optedOut := map[string]bool{}
seenOverride := map[string]bool{}
for _, row := range overrideRows {
@@ -383,6 +384,14 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
familyOverrides[email] = cells
}
switch row["Opted Out"] {
case "", "-", "FALSE":
case "TRUE":
optedOut[email] = true
default:
return nil, fmt.Errorf("overrides row %s has invalid Opted Out %q", email, row["Opted Out"])
}
if added {
if p.FullName == "" {
return nil, fmt.Errorf("added row %s has no full name", email)
@@ -401,15 +410,13 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
members := append(append([]string{}, hh.adults...), hh.kids...)
key := familyHash(members)
familyKeys[setKey] = key
family := Family{
model.Families[key] = Family{
Key: key,
Address: hh.address,
Phone: hh.phone,
AdultEmails: hh.adults,
KidEmails: hh.kids,
}
family.Name = familyNameFor(family, people)
model.Families[key] = family
}
for email, sets := range personHouseholds {
if p := people[email]; p.FamilyKey == "" {
@@ -439,6 +446,33 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
model.Families[key] = family
}
for email := range optedOut {
delete(people, email)
}
kept := []string{}
for _, email := range order {
if !optedOut[email] {
kept = append(kept, email)
}
}
order = kept
for key, family := range model.Families {
family.AdultEmails = without(family.AdultEmails, optedOut)
family.KidEmails = without(family.KidEmails, optedOut)
if len(family.AdultEmails)+len(family.KidEmails) == 0 {
delete(model.Families, key)
continue
}
family.Name = familyNameFor(family, people)
model.Families[key] = family
}
for _, p := range people {
p.ParentContactEmails = without(p.ParentContactEmails, optedOut)
}
for band, emails := range roomParents {
roomParents[band] = without(emails, optedOut)
}
if blobs != nil {
for _, p := range people {
local, _, _ := strings.Cut(p.Email, "@")
@@ -603,6 +637,16 @@ func bandLabel(band string) string {
return strings.Join(labels, " / ")
}
func without(list []string, drop map[string]bool) []string {
kept := []string{}
for _, item := range list {
if !drop[item] {
kept = append(kept, item)
}
}
return kept
}
func surname(fullName string) string {
fields := strings.Fields(fullName)
if len(fields) == 0 {
+20
View File
@@ -62,6 +62,26 @@ func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *
u := uploader{cache: cache, sheet: sheet, store: store}
mux.HandleFunc("POST /api/directory/upload", u.upload)
mux.HandleFunc("POST /api/directory/facts", u.facts)
mux.HandleFunc("POST /api/directory/optout", u.optOut)
}
func (u uploader) optOut(w http.ResponseWriter, r *http.Request) {
me := strings.ToLower(auth.Email(r))
if err := u.sheet.Upsert(appName, "Overrides", "Email", me, "Opted Out", "TRUE"); err != nil {
serverError(w, fmt.Errorf("opt out %s: %w", me, err))
return
}
logRow := changeLogRow(me, me, map[string]string{"Opted Out": ""})
if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil {
serverError(w, fmt.Errorf("append change log after opt out of %s: %w", me, err))
return
}
if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after opt out: %w", err))
return
}
log.Printf("optout: %s removed themselves from the directory", me)
w.WriteHeader(http.StatusNoContent)
}
func (u uploader) facts(w http.ResponseWriter, r *http.Request) {