From 2b6e77c7665ea0299422f0bce0c05383238204d8 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 16 Aug 2026 12:03:35 -0700 Subject: [PATCH] Add edit mode with granular self-service edits and child opt-out --- docs/data.md | 4 +- docs/plan.md | 4 - internal/directory/upload.go | 120 ++++++++++++++++++++++++-- tools/screenshot/main.go | 27 +++++- web/static/directory/app.js | 150 ++++++++++++++++++++++++++++----- web/static/directory/style.css | 39 +++++++++ 6 files changed, 308 insertions(+), 36 deletions(-) diff --git a/docs/data.md b/docs/data.md index e54cc3d..d2a2acd 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, 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. +- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. 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,7 +67,7 @@ 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. +**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 from their own page, parents set it for their kids (each with a confirmation spelling out the 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. diff --git a/docs/plan.md b/docs/plan.md index 1ee6724..b609a03 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -2,10 +2,6 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/data.md`, `docs/directory.md`, `docs/design.md`, `docs/pwa.md`, and `docs/deploy.md`. -## Directory app - -- Self-service flows: address update. - ## Hosting and deployment - Move from `gen-lang-client-0758114984` to the school's project: recreate the OAuth client there (Internal consent screen, only available inside the school's Workspace org, removes unverified-app friction), plus the service account, secrets, and service; re-share the spreadsheet and media drive with the new service account. diff --git a/internal/directory/upload.go b/internal/directory/upload.go index 3afe6a7..7c15f32 100644 --- a/internal/directory/upload.go +++ b/internal/directory/upload.go @@ -63,24 +63,132 @@ func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, 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) + mux.HandleFunc("POST /api/directory/edit", u.edit) +} + +func clearable(value string) string { + if value == "" { + return "-" + } + return value +} + +func (u uploader) edit(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) + field := r.FormValue("field") + value := strings.TrimSpace(r.FormValue("value")) + me := auth.Email(r) + model := u.cache.Model() + var person *Person + for i := range model.People { + if model.People[i].Email == key { + person = &model.People[i] + break + } + } + if person == nil { + http.Error(w, "no such person", http.StatusBadRequest) + return + } + + cells := map[string]string{} + previous := map[string]string{} + switch field { + case "preferred-name": + if !mayEdit(model, me, "person", key) { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + if value == "" || len(value) > 80 { + http.Error(w, "bad preferred name", http.StatusBadRequest) + return + } + base := person.LegalName + if base == "" { + base = person.FullName + } + cells["Preferred Name"] = value + cells["Full Name"] = value + " " + surname(base) + previous["Preferred Name"] = person.PreferredName + previous["Full Name"] = person.FullName + case "phone": + if !mayEdit(model, me, "person", key) { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + if len(value) > 40 { + http.Error(w, "bad phone number", http.StatusBadRequest) + return + } + cells["Phone"] = clearable(value) + previous["Phone"] = person.Phone + case "address": + if key != strings.ToLower(me) || !person.IsParent { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + family, ok := model.Families[person.FamilyKey] + if !ok { + http.Error(w, "no family record", http.StatusBadRequest) + return + } + if len(value) > 200 { + http.Error(w, "bad address", http.StatusBadRequest) + return + } + cells["Address"] = clearable(value) + previous["Address"] = family.Address + default: + http.Error(w, "bad field", http.StatusBadRequest) + return + } + + for column, cell := range cells { + if err := u.sheet.Upsert(appName, "Overrides", "Email", key, column, cell); err != nil { + serverError(w, fmt.Errorf("set %s for %s: %w", column, key, err)) + return + } + } + logRow := changeLogRow(me, key, previous) + if err := u.sheet.Append(appName, changeLogTable, changeLogHeader, logRow); err != nil { + serverError(w, fmt.Errorf("append change log after %s edit for %s: %w", field, key, err)) + return + } + if err := u.cache.Refresh(); err != nil { + serverError(w, fmt.Errorf("refresh model after %s edit: %w", field, err)) + return + } + log.Printf("edit: %s set %s on %s", me, field, key) + w.WriteHeader(http.StatusNoContent) } 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)) + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + me := auth.Email(r) + key := strings.ToLower(strings.TrimSpace(r.FormValue("key"))) + if key == "" { + http.Error(w, "bad opt out request", http.StatusBadRequest) return } - logRow := changeLogRow(me, me, map[string]string{"Opted Out": ""}) + if !mayEdit(u.cache.Model(), me, "person", key) { + http.Error(w, "not allowed to edit this record", http.StatusForbidden) + return + } + if err := u.sheet.Upsert(appName, "Overrides", "Email", key, "Opted Out", "TRUE"); err != nil { + serverError(w, fmt.Errorf("opt out %s: %w", key, err)) + return + } + logRow := changeLogRow(me, key, 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)) + serverError(w, fmt.Errorf("append change log after opt out of %s: %w", key, 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) + log.Printf("optout: %s removed %s from the directory", me, key) w.WriteHeader(http.StatusNoContent) } diff --git a/tools/screenshot/main.go b/tools/screenshot/main.go index 63a8267..1b9ea38 100644 --- a/tools/screenshot/main.go +++ b/tools/screenshot/main.go @@ -7,8 +7,10 @@ import ( "log" "os" "path/filepath" + "strings" "time" + "github.com/chromedp/cdproto/network" "github.com/chromedp/chromedp" ) @@ -17,6 +19,8 @@ func main() { out := flag.String("out", "screenshots/capture.png", "output png path") wait := flag.String("wait", "body", "css selector that must be visible before capturing") remote := flag.Bool("remote", false, "attach to the capture browser on localhost:9222 instead of launching headless chrome") + cookie := flag.String("cookie", "", "name=value cookie to set for localhost before navigating") + click := flag.String("click", "", "css selector to click after the wait selector appears") flag.Parse() ctx := context.Background() if *remote { @@ -28,14 +32,29 @@ func main() { defer cancelBrowser() ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second) defer cancelTimeout() + actions := []chromedp.Action{chromedp.EmulateViewport(1280, 800)} + if *cookie != "" { + name, value, ok := strings.Cut(*cookie, "=") + if !ok { + log.Fatal("[ERROR] -cookie must be name=value") + } + actions = append(actions, chromedp.ActionFunc(func(ctx context.Context) error { + return network.SetCookie(name, value).WithDomain("localhost").WithPath("/").Do(ctx) + })) + } var png []byte - err := chromedp.Run(ctx, - chromedp.EmulateViewport(1280, 800), + actions = append(actions, chromedp.Navigate(*url), chromedp.WaitVisible(*wait, chromedp.ByQuery), - chromedp.FullScreenshot(&png, 90), ) - if err != nil { + if *click != "" { + actions = append(actions, + chromedp.Click(*click, chromedp.ByQuery), + chromedp.Sleep(500*time.Millisecond), + ) + } + actions = append(actions, chromedp.FullScreenshot(&png, 90)) + if err := chromedp.Run(ctx, actions...); err != nil { log.Fatalf("[ERROR] capture %s: %v", *url, err) } if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { diff --git a/web/static/directory/app.js b/web/static/directory/app.js index c447123..bff1643 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -666,6 +666,59 @@ function copyButton(text) { return iconButton('copy', 'Copy', () => navigator.clipboard.writeText(text)); } +async function submitField(key, field, value, status) { + status.classList.remove('error'); + status.textContent = 'Saving…'; + const form = new FormData(); + form.append('key', key); + form.append('field', field); + form.append('value', value); + const res = await fetch('/api/directory/edit', {method: 'POST', body: form}); + if (!res.ok) { + status.classList.add('error'); + status.textContent = await res.text(); + return false; + } + await load(); + return true; +} + +function editPencil(title) { + const pencil = el('button', 'edit-icon inline'); + pencil.title = title; + pencil.append(svg('pencil')); + return pencil; +} + +function fieldEditor(anchor, pencil, opts) { + const box = el('div', 'field-editor'); + const input = el('input'); + input.type = 'text'; + input.value = opts.current || ''; + const note = el('div', 'field-note', "This doesn't affect the values shown in Veracross."); + const buttons = el('div', 'about-buttons'); + const status = el('div', 'media-status about-status'); + const save = el('button', 'media-button primary', 'Save'); + const cancel = el('button', 'media-button', 'Cancel'); + buttons.append(save, cancel); + if (opts.allowHide && opts.current) { + const hide = el('button', 'media-button', 'Hide'); + buttons.append(hide); + hide.addEventListener('click', () => opts.submit('', status)); + } + box.append(input, note, buttons, status); + cancel.addEventListener('click', () => { + box.remove(); + anchor.hidden = false; + pencil.hidden = false; + }); + save.addEventListener('click', () => opts.submit(input.value.trim(), status)); + anchor.hidden = true; + pencil.hidden = true; + anchor.after(box); + input.focus(); +} + function contactRow(value, buttons) { const row = el('div', 'contact-row'); row.append(value); @@ -757,6 +810,8 @@ function familyBand(p, family) { return band; } +let personEdit = null; + function renderPersonDetail(email) { const main = document.querySelector('#main'); main.replaceChildren(); @@ -772,7 +827,8 @@ function renderPersonDetail(email) { const grid = el('div', 'detail-grid'); const left = el('div'); const editable = canEditPerson(p.email); - if (p.photoUrl || editable) { + const editing = editable && personEdit === p.email; + if (p.photoUrl || editing) { const wrap = el('div', 'photo-wrap'); if (p.photoUrl) { const img = el('img', 'detail-photo'); @@ -783,7 +839,7 @@ function renderPersonDetail(email) { wrap.append(el('div', 'detail-photo detail-photo-empty')); } left.append(wrap); - if (editable) { + if (editing) { const status = el('div', 'media-status'); wrap.append(uploadIcon('camera', 'Upload photo', 'image/*', 'person', p.email, 'photo', status)); left.append(status); @@ -792,8 +848,28 @@ function renderPersonDetail(email) { grid.append(left); const right = el('div'); - right.append(el('div', 'role-label', roleLabel(p))); - right.append(el('h1', 'detail-name', p.fullName)); + const topRow = el('div', 'detail-top'); + topRow.append(el('div', 'role-label', roleLabel(p))); + if (editable) { + const toggle = el('button', 'media-button edit-toggle', editing ? 'Done' : 'Edit info'); + toggle.addEventListener('click', () => { + personEdit = editing ? null : p.email; + renderPersonDetail(email); + }); + topRow.append(toggle); + } + right.append(topRow); + const nameHeader = el('h1', 'detail-name'); + nameHeader.append(el('span', '', p.fullName)); + right.append(nameHeader); + if (editing) { + const pencil = editPencil('Edit preferred name'); + nameHeader.append(pencil); + pencil.addEventListener('click', () => fieldEditor(nameHeader, pencil, { + current: p.preferredName || '', + submit: (value, status) => submitField(p.email, 'preferred-name', value, status), + })); + } const nickname = displayNameLine(p); if (nickname) { right.append(el('div', 'detail-sub', nickname)); @@ -804,28 +880,56 @@ function renderPersonDetail(email) { right.append(el('div', 'detail-sub', chain)); } } - if (p.phone) { - right.append(contactRow(el('div', 'contact-value', p.phone), [ + if (p.phone || editing) { + const actions = p.phone ? [ copyButton(p.phone), iconButton('message', 'Text', 'sms:' + p.phone), iconButton('phone', 'Call', 'tel:' + p.phone), - ])); + ] : []; + const phoneValue = el('div', 'contact-value editable-value'); + phoneValue.append(el('span', '', p.phone || 'No phone number')); + const phoneRow = contactRow(phoneValue, actions); + right.append(phoneRow); + if (editing) { + const pencil = editPencil('Edit phone number'); + phoneValue.append(pencil); + pencil.addEventListener('click', () => fieldEditor(phoneRow, pencil, { + current: p.phone || '', + allowHide: true, + submit: (value, status) => submitField(p.email, 'phone', value, status), + })); + } } right.append(contactRow(el('div', 'contact-value', p.email), [ copyButton(p.email), iconButton('mail', 'Email', 'mailto:' + p.email), ])); const family = state.model.families[p.familyKey]; - if (family && family.address) { + const addressEditable = editing && family && p.email === document.body.dataset.userEmail && + (family.adultEmails || []).includes(p.email); + if (family && (family.address || addressEditable)) { const block = el('div'); block.append(el('div', 'field-label', 'Address')); - block.append(el('div', 'contact-value', family.address)); - right.append(contactRow(block, [ + const addressValue = el('div', 'contact-value editable-value'); + addressValue.append(el('span', '', family.address || 'No address')); + block.append(addressValue); + const actions = family.address ? [ copyButton(family.address), iconButton('map', 'Map', 'https://maps.google.com/?q=' + encodeURIComponent(family.address)), - ])); + ] : []; + const addressRow = contactRow(block, actions); + right.append(addressRow); + if (addressEditable) { + const pencil = editPencil('Edit address'); + addressValue.append(pencil); + pencil.addEventListener('click', () => fieldEditor(addressRow, pencil, { + current: family.address || '', + allowHide: true, + submit: (value, status) => submitField(p.email, 'address', value, status), + })); + } } - if (p.pronunciationUrl || editable) { + if (p.pronunciationUrl || editing) { right.append(el('div', 'pronounce-label', 'How do I pronounce this?')); if (p.pronunciationUrl) { const audio = el('audio', 'pronounce-player'); @@ -834,19 +938,19 @@ function renderPersonDetail(email) { audio.src = p.pronunciationUrl; right.append(audio); } - if (editable) { + if (editing) { right.append(pronounceEditor('person', p.email)); } } grid.append(right); content.append(grid); - if (p.facts || editable) { + if (p.facts || editing) { const header = el('h2', 'about-header', 'About Me'); content.append(header); const text = el('div', 'about-text', p.facts || ''); const status = el('div', 'media-status about-status'); - if (editable) { + if (editing) { const pencil = el('button', 'edit-icon inline'); pencil.title = 'Edit'; pencil.append(svg('pencil')); @@ -886,19 +990,25 @@ function renderPersonDetail(email) { content.append(text, status); } - if (p.email === document.body.dataset.userEmail) { + if (editing) { + const self = 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 button = el('button', 'media-button', + 'Remove ' + (self ? 'me' : firstName(p.fullName)) + ' from this directory'); const status = el('div', 'media-status'); button.addEventListener('click', async () => { - const message = 'This removes all of your data from the directory. ' + - 'For security, users not in the directory cannot access it. Continue?'; + const message = 'This removes all data about ' + (self ? 'you' : firstName(p.fullName)) + + ' from this directory. ' + + 'For security, users not in the directory cannot access it. ' + + "This doesn't affect the values shown in Veracross. Continue?"; if (!confirm(message)) { return; } status.classList.remove('error'); status.textContent = 'Removing…'; - const res = await fetch('/api/directory/optout', {method: 'POST'}); + const form = new FormData(); + form.append('key', p.email); + const res = await fetch('/api/directory/optout', {method: 'POST', body: form}); if (!res.ok) { status.classList.add('error'); status.textContent = await res.text(); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index b0290dc..245d5c6 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -1251,6 +1251,45 @@ a.list-row:hover { margin-top: 10px; } +.detail-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.detail-name { + display: flex; + align-items: center; + gap: 12px; +} + +.editable-value { + display: flex; + align-items: center; + gap: 10px; +} + +.field-editor { + margin: 8px 0; +} + +.field-editor input { + width: 100%; + max-width: 360px; + font: inherit; + font-size: 14px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.field-note { + font-size: 12px; + color: var(--muted); + margin-top: 6px; +} + .about-status { text-align: left; }