diff --git a/docs/data.md b/docs/data.md index fde4a08..e54cc3d 100644 --- a/docs/data.md +++ b/docs/data.md @@ -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: - **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. 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. +**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. ## Media blobs diff --git a/docs/plan.md b/docs/plan.md index 65bee5a..1ee6724 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da ## Directory app -- Self-service flows: address update, opt-out. +- Self-service flows: address update. ## Hosting and deployment diff --git a/internal/directory/load.go b/internal/directory/load.go index 580c407..880e28f 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -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 { diff --git a/internal/directory/upload.go b/internal/directory/upload.go index 1644a15..3afe6a7 100644 --- a/internal/directory/upload.go +++ b/internal/directory/upload.go @@ -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) { diff --git a/tools/createtabs/main.go b/tools/createtabs/main.go index 3675120..58db513 100644 --- a/tools/createtabs/main.go +++ b/tools/createtabs/main.go @@ -24,7 +24,7 @@ var tabs = []struct { "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", }}, {"Change Log", []string{ "Timestamp", "Actor", @@ -34,7 +34,7 @@ var tabs = []struct { "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", }}, } diff --git a/tools/oneoff/main.go b/tools/oneoff/main.go index 87c1b7b..5d05f7a 100644 --- a/tools/oneoff/main.go +++ b/tools/oneoff/main.go @@ -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 import ( @@ -12,17 +12,6 @@ import ( "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() { sheet := flag.String("sheet", "", "spreadsheet id") flag.Parse() @@ -35,60 +24,58 @@ func main() { if err != nil { log.Fatalf("[ERROR] create sheets client: %v", err) } - - meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title))").Do() + meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title,gridProperties(columnCount)))").Do() if err != nil { log.Fatalf("[ERROR] get spreadsheet: %v", err) } - importID := int64(-1) - for _, s := range meta.Sheets { - if s.Properties.Title == "Veracross Import" { - importID = s.Properties.SheetId - } - } - if importID < 0 { - log.Fatal("[ERROR] no Veracross Import tab") - } - - resp, err := svc.Spreadsheets.Values.Get(*sheet, "'Veracross Import'!AD1:AD1000").Do() - if err != nil { - log.Fatalf("[ERROR] read column AD: %v", err) - } - for i, row := range resp.Values { - if i == 0 { - if len(row) == 0 || fmt.Sprint(row[0]) != "household_2_person_2_phone_business" { - log.Fatalf("[ERROR] column AD header is %v, not the expected duplicate", row) + for _, tab := range []string{"Overrides", "Change Log"} { + var props *sheets.SheetProperties + for _, s := range meta.Sheets { + if s.Properties.Title == tab { + props = s.Properties } - 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 props == nil { + log.Fatalf("[ERROR] no %s tab", tab) } + resp, err := svc.Spreadsheets.Values.Get(*sheet, fmt.Sprintf("'%s'!1:1", tab)).Do() + if err != nil { + log.Fatalf("[ERROR] read %s header: %v", tab, err) + } + width := len(resp.Values[0]) + for _, cell := range resp.Values[0] { + if fmt.Sprint(cell) == "Opted Out" { + log.Fatalf("[ERROR] %s already has an Opted Out column", tab) + } + } + if int64(width) >= props.GridProperties.ColumnCount { + _, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{AppendDimension: &sheets.AppendDimensionRequest{ + SheetId: props.SheetId, + Dimension: "COLUMNS", + Length: 1, + }}}, + }).Do() + if err != nil { + log.Fatalf("[ERROR] widen %s: %v", tab, err) + } + } + cell := fmt.Sprintf("'%s'!%s1", tab, columnName(width)) + _, err = svc.Spreadsheets.Values.Update(*sheet, cell, &sheets.ValueRange{ + Values: [][]interface{}{{"Opted Out"}}, + }).ValueInputOption("RAW").Do() + if err != nil { + log.Fatalf("[ERROR] write %s header: %v", tab, err) + } + log.Printf("added Opted Out to %s at %s", tab, cell) } - _, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ - Requests: []*sheets.Request{{DeleteDimension: &sheets.DeleteDimensionRequest{ - Range: &sheets.DimensionRange{ - SheetId: importID, - Dimension: "COLUMNS", - StartIndex: 29, - EndIndex: 30, - }, - }}}, - }).Do() - if err != nil { - log.Fatalf("[ERROR] delete column AD: %v", 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{ - Values: [][]interface{}{values}, - }).ValueInputOption("RAW").Do() - if err != nil { - log.Fatalf("[ERROR] rewrite change log header: %v", err) - } - 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 } diff --git a/web/static/directory/app.js b/web/static/directory/app.js index a513088..d175f59 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -885,6 +885,29 @@ function renderPersonDetail(email) { } 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); if (family) {