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
+3 -1
View File
@@ -53,7 +53,7 @@ The explosion turns each import row into one student record plus up to four adul
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag: Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker. - **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
- **Self-service text** (app): facts, pronouns, address preferences. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet. - **Self-service text** (app): facts, pronouns, address preferences, and the Opted Out flag. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet.
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation. - **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead: Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
@@ -67,6 +67,8 @@ Flagged rows must supply every field the model requires; unflagged rows can be a
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults. Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it themselves from their own profile page (with a confirmation spelling out both consequences), or an admin sets the cell by hand.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history. Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
## Media blobs ## Media blobs
+1 -1
View File
@@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da
## Directory app ## Directory app
- Self-service flows: address update, opt-out. - Self-service flows: address update.
## Hosting and deployment ## Hosting and deployment
+48 -4
View File
@@ -47,7 +47,7 @@ var overrideColumns = []string{
"Email", "Added", "Full Name", "Legal Name", "Preferred Name", "Email", "Added", "Full Name", "Legal Name", "Preferred Name",
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts", "Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent", "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 { type BlobChecker interface {
@@ -282,6 +282,7 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
bandSet[band] = true bandSet[band] = true
} }
roomParents := map[string][]string{} roomParents := map[string][]string{}
optedOut := map[string]bool{}
seenOverride := map[string]bool{} seenOverride := map[string]bool{}
for _, row := range overrideRows { for _, row := range overrideRows {
@@ -383,6 +384,14 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
familyOverrides[email] = cells 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 added {
if p.FullName == "" { if p.FullName == "" {
return nil, fmt.Errorf("added row %s has no full name", email) 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...) members := append(append([]string{}, hh.adults...), hh.kids...)
key := familyHash(members) key := familyHash(members)
familyKeys[setKey] = key familyKeys[setKey] = key
family := Family{ model.Families[key] = Family{
Key: key, Key: key,
Address: hh.address, Address: hh.address,
Phone: hh.phone, Phone: hh.phone,
AdultEmails: hh.adults, AdultEmails: hh.adults,
KidEmails: hh.kids, KidEmails: hh.kids,
} }
family.Name = familyNameFor(family, people)
model.Families[key] = family
} }
for email, sets := range personHouseholds { for email, sets := range personHouseholds {
if p := people[email]; p.FamilyKey == "" { if p := people[email]; p.FamilyKey == "" {
@@ -439,6 +446,33 @@ func LoadModel(source data.Source, blobs BlobChecker) (*Model, error) {
model.Families[key] = family 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 { if blobs != nil {
for _, p := range people { for _, p := range people {
local, _, _ := strings.Cut(p.Email, "@") local, _, _ := strings.Cut(p.Email, "@")
@@ -603,6 +637,16 @@ func bandLabel(band string) string {
return strings.Join(labels, " / ") 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 { func surname(fullName string) string {
fields := strings.Fields(fullName) fields := strings.Fields(fullName)
if len(fields) == 0 { 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} u := uploader{cache: cache, sheet: sheet, store: store}
mux.HandleFunc("POST /api/directory/upload", u.upload) mux.HandleFunc("POST /api/directory/upload", u.upload)
mux.HandleFunc("POST /api/directory/facts", u.facts) 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) { func (u uploader) facts(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -24,7 +24,7 @@ var tabs = []struct {
"New to Helios", "Pronouns", "Facts", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Grade", "Classroom", "Crew",
"Phone", "Job Title", "Department", "Grade Band", "Room Parent", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Address", "Family Phone", "Family Photo Caption", "Opted Out",
}}, }},
{"Change Log", []string{ {"Change Log", []string{
"Timestamp", "Actor", "Timestamp", "Actor",
@@ -34,7 +34,7 @@ var tabs = []struct {
"New to Helios", "Pronouns", "Facts", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Grade", "Classroom", "Crew",
"Phone", "Job Title", "Department", "Grade Band", "Room Parent", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Address", "Family Phone", "Family Photo Caption", "Opted Out",
}}, }},
} }
+34 -47
View File
@@ -1,4 +1,4 @@
// Command oneoff drops the duplicated trailing import column and reshapes the Change Log header. // Command oneoff appends the Opted Out column to the Overrides and Change Log tabs.
package main package main
import ( import (
@@ -12,17 +12,6 @@ import (
"google.golang.org/api/sheets/v4" "google.golang.org/api/sheets/v4"
) )
var changeLogHeader = []string{
"Timestamp", "Actor",
"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",
}
func main() { func main() {
sheet := flag.String("sheet", "", "spreadsheet id") sheet := flag.String("sheet", "", "spreadsheet id")
flag.Parse() flag.Parse()
@@ -35,60 +24,58 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err) log.Fatalf("[ERROR] create sheets client: %v", err)
} }
meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title,gridProperties(columnCount)))").Do()
meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title))").Do()
if err != nil { if err != nil {
log.Fatalf("[ERROR] get spreadsheet: %v", err) log.Fatalf("[ERROR] get spreadsheet: %v", err)
} }
importID := int64(-1) for _, tab := range []string{"Overrides", "Change Log"} {
var props *sheets.SheetProperties
for _, s := range meta.Sheets { for _, s := range meta.Sheets {
if s.Properties.Title == "Veracross Import" { if s.Properties.Title == tab {
importID = s.Properties.SheetId props = s.Properties
} }
} }
if importID < 0 { if props == nil {
log.Fatal("[ERROR] no Veracross Import tab") log.Fatalf("[ERROR] no %s tab", tab)
} }
resp, err := svc.Spreadsheets.Values.Get(*sheet, fmt.Sprintf("'%s'!1:1", tab)).Do()
resp, err := svc.Spreadsheets.Values.Get(*sheet, "'Veracross Import'!AD1:AD1000").Do()
if err != nil { if err != nil {
log.Fatalf("[ERROR] read column AD: %v", err) log.Fatalf("[ERROR] read %s header: %v", tab, err)
} }
for i, row := range resp.Values { width := len(resp.Values[0])
if i == 0 { for _, cell := range resp.Values[0] {
if len(row) == 0 || fmt.Sprint(row[0]) != "household_2_person_2_phone_business" { if fmt.Sprint(cell) == "Opted Out" {
log.Fatalf("[ERROR] column AD header is %v, not the expected duplicate", row) log.Fatalf("[ERROR] %s already has an Opted Out column", tab)
}
continue
}
if len(row) > 0 && fmt.Sprint(row[0]) != "" {
log.Fatalf("[ERROR] column AD row %d has data %q; not deleting", i+1, row[0])
} }
} }
if int64(width) >= props.GridProperties.ColumnCount {
_, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ _, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{
Requests: []*sheets.Request{{DeleteDimension: &sheets.DeleteDimensionRequest{ Requests: []*sheets.Request{{AppendDimension: &sheets.AppendDimensionRequest{
Range: &sheets.DimensionRange{ SheetId: props.SheetId,
SheetId: importID,
Dimension: "COLUMNS", Dimension: "COLUMNS",
StartIndex: 29, Length: 1,
EndIndex: 30,
},
}}}, }}},
}).Do() }).Do()
if err != nil { if err != nil {
log.Fatalf("[ERROR] delete column AD: %v", err) log.Fatalf("[ERROR] widen %s: %v", tab, err)
} }
log.Print("deleted duplicate import column AD")
values := make([]interface{}, len(changeLogHeader))
for i, h := range changeLogHeader {
values[i] = h
} }
_, err = svc.Spreadsheets.Values.Update(*sheet, "'Change Log'!1:1", &sheets.ValueRange{ cell := fmt.Sprintf("'%s'!%s1", tab, columnName(width))
Values: [][]interface{}{values}, _, err = svc.Spreadsheets.Values.Update(*sheet, cell, &sheets.ValueRange{
Values: [][]interface{}{{"Opted Out"}},
}).ValueInputOption("RAW").Do() }).ValueInputOption("RAW").Do()
if err != nil { if err != nil {
log.Fatalf("[ERROR] rewrite change log header: %v", err) log.Fatalf("[ERROR] write %s header: %v", tab, err)
}
log.Printf("added Opted Out to %s at %s", tab, cell)
} }
log.Printf("rewrote Change Log header with %d columns", len(changeLogHeader)) }
func columnName(idx int) string {
name := ""
for idx >= 0 {
name = string(rune('A'+idx%26)) + name
idx = idx/26 - 1
}
return name
} }
+23
View File
@@ -885,6 +885,29 @@ function renderPersonDetail(email) {
} }
content.append(text, status); content.append(text, status);
} }
if (p.email === document.body.dataset.userEmail) {
const header = el('h2', 'about-header', 'Privacy');
const button = el('button', 'media-button', 'Remove me from the directory');
const status = el('div', 'media-status');
button.addEventListener('click', async () => {
const message = 'This removes all of your data from the directory. ' +
'Users not in the directory cannot access it, for security. Continue?';
if (!confirm(message)) {
return;
}
status.classList.remove('error');
status.textContent = 'Removing…';
const res = await fetch('/api/directory/optout', {method: 'POST'});
if (!res.ok) {
status.classList.add('error');
status.textContent = await res.text();
return;
}
location.reload();
});
content.append(header, button, status);
}
main.append(content); main.append(content);
if (family) { if (family) {