Add edit mode with granular self-service edits and child opt-out

This commit is contained in:
Ian Gulliver
2026-08-16 12:03:35 -07:00
parent 4cd128e242
commit 2b6e77c766
6 changed files with 308 additions and 36 deletions
+2 -2
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, 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.
-4
View File
@@ -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.
+114 -6
View File
@@ -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)
}
+23 -4
View File
@@ -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 {
+130 -20
View File
@@ -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();
+39
View File
@@ -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;
}