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:
- **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
+1 -1
View File
@@ -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
+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) {
+2 -2
View File
@@ -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",
}},
}
+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
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 _, tab := range []string{"Overrides", "Change Log"} {
var props *sheets.SheetProperties
for _, s := range meta.Sheets {
if s.Properties.Title == "Veracross Import" {
importID = s.Properties.SheetId
if s.Properties.Title == tab {
props = s.Properties
}
}
if importID < 0 {
log.Fatal("[ERROR] no Veracross Import tab")
if props == nil {
log.Fatalf("[ERROR] no %s tab", tab)
}
resp, err := svc.Spreadsheets.Values.Get(*sheet, "'Veracross Import'!AD1:AD1000").Do()
resp, err := svc.Spreadsheets.Values.Get(*sheet, fmt.Sprintf("'%s'!1:1", tab)).Do()
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 {
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)
}
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])
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{{DeleteDimension: &sheets.DeleteDimensionRequest{
Range: &sheets.DimensionRange{
SheetId: importID,
Requests: []*sheets.Request{{AppendDimension: &sheets.AppendDimensionRequest{
SheetId: props.SheetId,
Dimension: "COLUMNS",
StartIndex: 29,
EndIndex: 30,
},
Length: 1,
}}},
}).Do()
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{
Values: [][]interface{}{values},
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] 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);
}
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) {