Add edit mode with granular self-service edits and child opt-out
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user