Add self-service person-level opt-out with full removal at load
This commit is contained in:
+3
-1
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user