diff --git a/docs/data.md b/docs/data.md index ad67b3d..e59bcfb 100644 --- a/docs/data.md +++ b/docs/data.md @@ -1,6 +1,8 @@ # Data model -The entities the directory serves, and how they are assembled. Structured data lives in one Google Sheet in the community shared drive, reached through drive membership rather than project IAM; blobs (photos, audio) are objects in the media bucket, reached through project IAM. Each has exactly one home. The organized model is held in memory — nothing computed is ever written back to the sheet. +The entities the directory serves, and how they are assembled. Structured data lives in two Google Sheets in the community shared drive, reached through drive membership rather than project IAM; blobs (photos, audio) are objects in the media bucket, reached through project IAM. Each has exactly one home. The organized model is held in memory — nothing computed is ever written back to either sheet. + +The `Directory` sheet holds the records. The `Preferences` sheet is a Google Form's response sheet, owned by the form and never written by the app, carrying each family's sharing consent. ## Spreadsheet layout @@ -11,6 +13,10 @@ The entities the directory serves, and how they are assembled. Structured data l | Overrides | hand + app | The entire local layer: admin corrections, app-written self-service text, and added people, in canonical model columns keyed by email. | | Change Log | app | Append-only audit trail mirroring the Overrides columns: one row per change, holding the previous values. | +The `Preferences` spreadsheet has a single form-owned tab, `Sheet1`: `Timestamp`, `Email Address`, `Communication Opt-In Status`, and `You have my permission to share the folllowing:` — the misspelling is the form's, and the loader matches it verbatim. + +Because the form is edited outside this repository and its wording *is* the data, every value in it is matched exactly and anything unrecognized is fatal — a reworded consent sentence, a renamed permission option, a newly added option, or a column the loader has never heard of all stop the load rather than being partially understood. A form edit therefore surfaces as a refused startup, never as a family's preference read the wrong way. Only two things are deliberately tolerant: an empty permission cell, which legitimately means "share neither", and a response whose email matches nobody, which is skipped. + School structure lives nowhere in the sheet: membership and the classroom and crew names themselves derive from person records, and the remaining fixed structure — band identities, grade progression, department order — is code constants (see Classrooms and grades). ## Load pipeline @@ -18,8 +24,9 @@ School structure lives nowhere in the sheet: membership and the classroom and cr 1. Read the raw import. 2. Transform each import row into canonical person and household records (explosion, below). The Name to Email mapping applies during this step, since it fixes the key everything else uses. 3. Apply the Overrides tab onto the canonical records by email: unflagged rows patch existing records, flagged rows create new ones. -4. List the media bucket and attach photo and pronunciation blobs to records by object-name convention. -5. Hold the organized result in memory; the server refuses to start if the load fails, and the model reloads periodically. +4. Apply the Preferences sheet, masking the contact data families chose not to share. +5. List the media bucket and attach photo and pronunciation blobs to records by object-name convention. +6. Hold the organized result in memory; the server refuses to start if the load fails, and the model reloads periodically. Because Overrides rows are authored post-transform, their values skip import normalization — so canonical-value validation runs on every layer, not just the import. @@ -71,6 +78,26 @@ Family-level fields (address, family photo caption, family phone) ride on a pare 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; bucket object versions are their history. +## Preferences + +One form response per submission, keyed by the submitter's email. Families resubmit freely — duplicates are ordinary, and a household's two adults may answer differently — so the sheet is a log, not a table of current state. + +Resolution: + +- **A person's answer governs their whole family.** The submitter is matched by lowercased email, their household located, and the answer applied to every adult and kid in it. A submitter with no household (staff) governs only themselves. A submission matching no person in the model is skipped: the form is open to the whole Workspace domain, and a stray response must not be able to stop the server. +- **Last submission wins per family**, by timestamp rather than sheet order. The 33 seconds in which one family opted out and back in are the whole reason this is a timestamp comparison. +- **A student in two households** is governed by both. Where the two disagree the stricter answer holds — masked beats shared, opted out beats opted in — so the outcome never depends on map ordering. + +`Communication Opt-In Status` is one of two fixed sentences, recorded as explicit opt-in or explicit opt-out. `You have my permission to share the folllowing:` is a multi-select of `Home Address` and `Adult Phone Number`; an item's *absence* is the instruction. Missing address permission clears the family address, which also drops it from the map, since geocoding skips empty addresses. Missing phone permission clears the household phone and every adult's phone in that family. An opt-out row carries no permissions and therefore masks both. + +A family with no submission at all is **default**, which today means opt-in with nothing masked. Only the three-state flag distinguishes them from an explicit opt-in, and that flag exists so the default can be inverted without touching the resolution logic. + +Explicit opt-out is recorded and not yet enforced; removal from the model remains the Overrides `Opted Out` flag, set by hand. + +## Student phone numbers + +Student phone numbers are never shown, whatever any sheet says. The Veracross import does not read `student_phone_mobile`, and the loader clears the phone of anyone flagged as a student after the Overrides layer, so neither an admin correction nor a self-service edit can surface one. No preference governs this and no flag records it — it is not a family's choice to make. + ## Media blobs Photos and pronunciation recordings are objects in the media bucket `gs://heliosian-media`, under a `people/` or `families/` prefix and named by convention: `-photo` and `-pronunciation` for people, `-photo` and `-pronunciation` for families, each keeping its source extension. Presence means existence — no sheet cell records a filename — and freshness comes from the object generation. @@ -91,7 +118,8 @@ One record per person, all roles in one shape: - **Facts**: optional about-me text — first-person blurbs for students, professional bios for staff. - **Student fields**: grade, classroom, and crew from the homeroom split; parent contact emails derive from the student's household adults. - **Staff fields**: job title, department, grade band, and classroom/crew assignment for teaching staff. -- **Contact**: email always; phone optional. +- **Contact**: email always; phone optional, and absent for every student. +- **Sharing**: the family's consent state — explicit opt-in, explicit opt-out, or default — plus flags recording whether the address and phone were masked by preference. The phone flag is never set on a student, whose phone is withheld by the blanket rule rather than by any choice. - **Year rollover**: next-year grade and band derive from the grade progression constant, so the directory can flip to the new school year. ## Family @@ -102,8 +130,8 @@ A household groups adults and kids; a student belongs to one household normally, - **Members**: the adults in the household and the students whose rows name it. - **Photo and caption**: the family photo from the media bucket plus a who's-who description naming everyone in it. - **Pronunciation**: optional audio recording of the family name, from the media bucket. -- **Address**: as much as the family chooses to share — full postal address or just city and state, seeded from the import and updatable via self-service. -- **Phone**: optional household phone. +- **Address**: as much as the family chooses to share — full postal address or just city and state, seeded from the import and updatable via self-service. Cleared, with a flag recording it, when the family withheld address permission. +- **Phone**: optional household phone, cleared and flagged alongside the adults' phones when the family withheld phone permission. ## Classrooms and grades @@ -134,8 +162,9 @@ The loader hard-fails — no fallbacks, server refuses to start — on: - a flagged Overrides row colliding with an imported person - conflicting values for the same adult across import rows - an invalid canonical value from any layer +- a Preferences column the loader does not know, or a Preferences row with a malformed email, an unparseable timestamp, an unrecognized opt-in sentence, or an unrecognized permission item -The import procedure is manual today: `tools/writetab` writes the Veracross CSV export into the Veracross Import tab (header-checked), and `tools/loadcheck` re-runs the full pipeline against the sheet and prints a model summary. A single import tool that also reports the local layer's health beyond the fatal checks — useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family, including family blobs orphaned by a membership change — is planned (`docs/plan.md`). `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows. +The import procedure is manual today: `tools/writetab` writes the Veracross CSV export into the Veracross Import tab (header-checked), and `tools/loadcheck` re-runs the full pipeline against both sheets and prints a model summary, including the opt-in/opt-out/default split and the masked counts. A single import tool that also reports the local layer's health beyond the fatal checks — useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family, including family blobs orphaned by a membership change — is planned (`docs/plan.md`). `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows. ## Sourcing diff --git a/docs/deploy.md b/docs/deploy.md index 30ef42e..d38b9ce 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -20,9 +20,9 @@ The Dockerfile builds in two stages: a `golang` stage compiles the static binary `tools/deploy` holds the full service configuration and is the only place it is written down: - DIRECTORY_SHEET= go run ./tools/deploy + DIRECTORY_SHEET= PREFERENCES_SHEET= go run ./tools/deploy -It deploys the `latest` image with every setting the pipeline does not touch, so it both creates the service from nothing and repairs drift on an existing one. The spreadsheet id comes from the environment and the OAuth client id from `creds/oauth-client.json` — the same resolution the server itself uses — so neither is written into the repository. +It deploys the `latest` image with every setting the pipeline does not touch, so it both creates the service from nothing and repairs drift on an existing one. The spreadsheet ids come from the environment and the OAuth client id from `creds/oauth-client.json` — the same resolution the server itself uses — so none of them is written into the repository. Why each setting is what it is: @@ -42,6 +42,7 @@ Cloud Run injects `PORT`; the server honors it. Plain environment variables: - `DIRECTORY_SHEET` — the production spreadsheet id: the `Directory` sheet living in the community shared drive. +- `PREFERENCES_SHEET` — the `Preferences` sheet in the same shared drive: the sharing-consent form's response spreadsheet. - `GOOGLE_CLIENT_ID` — the OAuth web client id; not a secret (it is embedded in the login page). Secret Manager secrets, delivered as environment variables. Values are used raw, so payloads must not carry trailing newlines: diff --git a/docs/dev.md b/docs/dev.md index 6671f25..40807ad 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -8,15 +8,15 @@ http://localhost:8080 (override with `PORT`). With `DIRECTORY_SHEET` unset the s Sample-mode limits: the map section needs a real Maps JavaScript key (`GOOGLE_MAPS_BROWSER_KEY`) to render tiles, and self-service edits and media uploads need real-data mode — there is no writable backend or blob store behind the sample CSVs. -`sampledata/` mirrors the production Sheets layout: one directory per app, one CSV per table, first row is the schema, served through the same data-source interface the Sheets backend implements. It stays fictional — real community data never goes here. +`sampledata/` mirrors the production Sheets layout: one directory per app, one CSV per table, first row is the schema, served through the same data-source interface the Sheets backend implements. It stays fictional — real community data never goes here. `sampledata/preferences/` is the consent form's response sheet, exercising every combination the loader has to resolve: both permissions, each alone, an opt-out, a superseded submission, and a two-household student whose parents answered differently. In sample mode `tools/screenshot` captures pages directly, no session needed (see `docs/screenshots.md`). ## Real data - DIRECTORY_SHEET= go run . + DIRECTORY_SHEET= PREFERENCES_SHEET= go run . -serves from the production spreadsheet and media bucket (see `docs/data.md`) and turns on the full stack. The model loads at startup — the server refuses to start if the load fails — and reloads every five minutes. Real data never leaves the process: nothing is written to disk. Requirements: +serves from the production spreadsheets and media bucket (see `docs/data.md`) and turns on the full stack. Both ids are required together; the server refuses to start with only one. The model loads at startup — the server refuses to start if the load fails — and reloads every five minutes. Real data never leaves the process: nothing is written to disk. Requirements: - **Sign-in** — everything sits behind Google sign-in restricted to the school's Workspace domain (API paths get a 401 instead of the login page). The OAuth web client is read from `creds/oauth-client.json` or `GOOGLE_CLIENT_ID`; its authorized JavaScript origins must include `http://localhost:8080`. The server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts. - **Data access** — Google credentials come from application-default credentials impersonating the data service account, set up once per machine: @@ -48,7 +48,7 @@ Each runs as `go run ./tools/`. The sheet, drive, and bucket tools authent - `deploy` — apply the full production service configuration (needs `DIRECTORY_SHEET`); see `docs/deploy.md` - `startserver` — launch the app detached, wait for it to listen, print the pid, log path, and a minted session cookie (needs `SESSION_KEY` and `DIRECTORY_SHEET`) - `cookie` — print a signed session cookie for local API testing -- `loadcheck` — run the full load pipeline against a sheet and print a model summary +- `loadcheck` — run the full load pipeline against the directory and preferences sheets and print a model summary - `columns` — print each directory table's column names from the configured source - `findsheet` — list spreadsheets visible to the service account - `sheets` — dump a sheet's tabs, sizes, and header rows diff --git a/internal/directory/load.go b/internal/directory/load.go index fb16841..6c972e0 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -8,12 +8,18 @@ import ( "regexp" "sort" "strings" + "time" "heliosian/internal/data" ) const appName = "directory" +const ( + preferencesApp = "preferences" + preferencesTab = "Sheet1" +) + var gradeOrder = []string{ "Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4", "Grade 5", "Grade 6", "Grade 7", "Grade 8", @@ -39,9 +45,28 @@ var departmentOrder = []string{ } var importColumns = []string{ - "entry_sort_name", "student_full_name", "student_classifications", "student_email", "student_phone_mobile", + "entry_sort_name", "student_full_name", "student_classifications", "student_email", } +// The permission column's header is misspelled in the form itself; it must match verbatim. +const ( + preferenceTimestamp = "Timestamp" + preferenceEmail = "Email Address" + preferenceStatus = "Communication Opt-In Status" + preferencePermission = "You have my permission to share the folllowing:" +) + +const ( + optInAnswer = "I agree to have family names and emails in the Helios Community Apps" + optOutAnswer = "Please remove all family names and emails from the Helios Community Apps. " + + "I understand that we will be unable to access the Helios Who directory, " + + "the volunteer portal and Spring Celebration fun(d)raiser events." + sharePhone = "Adult Phone Number (if provided on Veracross)" + shareAddress = "Home Address (if provided on Veracross)" +) + +const preferenceTimeFormat = "1/2/2006 15:04:05" + var overrideColumns = []string{ "Email", "Added", "Full Name", "Legal Name", "Preferred Name", "Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts", @@ -59,6 +84,8 @@ type parsedName struct { var nameForm = regexp.MustCompile(`^(.+?) \((.+?)\) (.+)$`) +var emailForm = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`) + func parseName(raw string) parsedName { raw = strings.Join(strings.Fields(raw), " ") m := nameForm.FindStringSubmatch(raw) @@ -92,6 +119,22 @@ type familyCells struct { hasAddress, hasPhone, hasCaption bool } +func exactColumns(table string, header, wanted []string) error { + if err := requireColumns(table, header, wanted); err != nil { + return err + } + known := map[string]bool{} + for _, w := range wanted { + known[w] = true + } + for _, h := range header { + if !known[h] { + return fmt.Errorf("table %s has unexpected column %q", table, h) + } + } + return nil +} + func requireColumns(table string, header, wanted []string) error { present := map[string]bool{} for _, h := range header { @@ -116,9 +159,10 @@ type loader struct { blobs BlobChecker static BlobChecker - importRows []map[string]string - overrideRows []map[string]string - nameToEmail map[string]string + importRows []map[string]string + overrideRows []map[string]string + preferenceRows []map[string]string + nameToEmail map[string]string people map[string]*Person order []string @@ -150,7 +194,9 @@ func LoadModel(source data.Source, blobs, static BlobChecker) (*Model, error) { func() error { return l.readTables(source) }, l.transformImport, l.applyOverrides, + l.hideStudentPhones, l.buildFamilies, + l.applyPreferences, l.removeOptedOut, l.attachBlobs, l.sortPeople, @@ -189,6 +235,16 @@ func (l *loader) readTables(source data.Source) error { return err } l.overrideRows = overrideRows + preferenceHeader, preferenceRows, err := source.Table(preferencesApp, preferencesTab) + if err != nil { + return err + } + if err := exactColumns(preferencesTab, preferenceHeader, []string{ + preferenceTimestamp, preferenceEmail, preferenceStatus, preferencePermission, + }); err != nil { + return err + } + l.preferenceRows = preferenceRows l.nameToEmail = map[string]string{} for _, row := range mapRows { @@ -264,7 +320,6 @@ func (l *loader) transformImport() error { student := &Person{ Email: email, FullName: n.display, LegalName: n.legal, PreferredName: n.preferred, IsStudent: true, Grade: classifications.GradeLevel, Classroom: classroom, Crew: crew, - Phone: row["student_phone_mobile"], } l.people[email] = student l.order = append(l.order, email) @@ -455,6 +510,15 @@ func (l *loader) applyOverrides() error { return nil } +func (l *loader) hideStudentPhones() error { + for _, p := range l.people { + if p.IsStudent { + p.Phone = "" + } + } + return nil +} + func (l *loader) buildFamilies() error { for _, setKey := range l.householdOrder { hh := l.households[setKey] @@ -499,6 +563,113 @@ func (l *loader) buildFamilies() error { return nil } +type preference struct { + when time.Time + status OptStatus + address bool + phone bool +} + +func parsePreference(row map[string]string) (string, preference, error) { + email := strings.ToLower(row[preferenceEmail]) + if !emailForm.MatchString(email) { + return "", preference{}, fmt.Errorf("preferences row %v has invalid email %q", row, row[preferenceEmail]) + } + when, err := time.Parse(preferenceTimeFormat, row[preferenceTimestamp]) + if err != nil { + return "", preference{}, fmt.Errorf("preferences row %s has invalid timestamp %q", email, row[preferenceTimestamp]) + } + pref := preference{when: when} + switch row[preferenceStatus] { + case optInAnswer: + pref.status = OptIn + case optOutAnswer: + pref.status = OptOut + default: + return "", preference{}, fmt.Errorf("preferences row %s has unknown opt-in status %q", email, row[preferenceStatus]) + } + if cell := row[preferencePermission]; cell != "" { + for _, item := range strings.Split(cell, ", ") { + switch { + case item == shareAddress && !pref.address: + pref.address = true + case item == sharePhone && !pref.phone: + pref.phone = true + default: + return "", preference{}, fmt.Errorf("preferences row %s grants invalid permission %q", email, item) + } + } + } + return email, pref, nil +} + +func (l *loader) applyPreferences() error { + byFamily := map[string]preference{} + byPerson := map[string]preference{} + for _, row := range l.preferenceRows { + email, pref, err := parsePreference(row) + if err != nil { + return err + } + if _, ok := l.people[email]; !ok { + continue + } + sets := l.personHouseholds[email] + if len(sets) == 0 { + if current, ok := byPerson[email]; !ok || pref.when.After(current.when) { + byPerson[email] = pref + } + continue + } + for _, set := range sets { + key := l.familyKeys[set] + if current, ok := byFamily[key]; !ok || pref.when.After(current.when) { + byFamily[key] = pref + } + } + } + + for key, pref := range byFamily { + family := l.model.Families[key] + if !pref.address { + family.Address = "" + family.AddressMasked = true + } + if !pref.phone { + family.Phone = "" + family.PhoneMasked = true + } + l.model.Families[key] = family + } + + for _, email := range l.order { + p := l.people[email] + p.OptStatus = OptDefault + governing := []preference{} + for _, set := range l.personHouseholds[email] { + if pref, ok := byFamily[l.familyKeys[set]]; ok { + governing = append(governing, pref) + } + } + if pref, ok := byPerson[email]; ok { + governing = append(governing, pref) + } + for _, pref := range governing { + if pref.status == OptOut || p.OptStatus == OptDefault { + p.OptStatus = pref.status + } + if !pref.address { + p.AddressMasked = true + } + if !pref.phone && !p.IsStudent { + p.PhoneMasked = true + p.Phone = "" + } + } + } + return nil +} + func (l *loader) removeOptedOut() error { for email := range l.optedOut { delete(l.people, email) diff --git a/internal/directory/model.go b/internal/directory/model.go index c120e34..cbc56d2 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -1,5 +1,13 @@ package directory +type OptStatus string + +const ( + OptDefault OptStatus = "default" + OptIn OptStatus = "in" + OptOut OptStatus = "out" +) + type Person struct { Email string `json:"email"` FullName string `json:"fullName"` @@ -22,6 +30,10 @@ type Person struct { JobTitle string `json:"jobTitle,omitempty"` Department string `json:"department,omitempty"` GradeBand string `json:"gradeBand,omitempty"` + + OptStatus OptStatus `json:"optStatus"` + AddressMasked bool `json:"addressMasked,omitempty"` + PhoneMasked bool `json:"phoneMasked,omitempty"` } type Family struct { @@ -36,6 +48,8 @@ type Family struct { PronunciationURL string `json:"pronunciationUrl,omitempty"` AdultEmails []string `json:"adultEmails,omitempty"` KidEmails []string `json:"kidEmails,omitempty"` + AddressMasked bool `json:"addressMasked,omitempty"` + PhoneMasked bool `json:"phoneMasked,omitempty"` } type Classroom struct { diff --git a/main.go b/main.go index 6416408..bd89ac9 100644 --- a/main.go +++ b/main.go @@ -98,7 +98,11 @@ func main() { source = data.Dir{Root: "sampledata"} log.Printf("DIRECTORY_SHEET not set, serving sample data as %s", sampleUser) } else { - sheet, err := data.NewSheet(map[string]string{"directory": sheetID}) + preferencesID := os.Getenv("PREFERENCES_SHEET") + if preferencesID == "" { + log.Fatal("[ERROR] PREFERENCES_SHEET is required alongside DIRECTORY_SHEET") + } + sheet, err := data.NewSheet(map[string]string{"directory": sheetID, "preferences": preferencesID}) if err != nil { log.Fatalf("[ERROR] load directory sheet: %v", err) } diff --git a/sampledata/preferences/Sheet1.csv b/sampledata/preferences/Sheet1.csv new file mode 100644 index 0000000..0a1c184 --- /dev/null +++ b/sampledata/preferences/Sheet1.csv @@ -0,0 +1,11 @@ +Timestamp,Email Address,Communication Opt-In Status,You have my permission to share the folllowing: +8/18/2026 9:12:04,elena.torres@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,"Home Address (if provided on Veracross), Adult Phone Number (if provided on Veracross)" +8/18/2026 10:03:41,mina.park@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,Adult Phone Number (if provided on Veracross) +8/18/2026 11:20:19,layla.haddad@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,Home Address (if provided on Veracross) +8/18/2026 12:44:02,dana.hawkins@heliosschool.org,"Please remove all family names and emails from the Helios Community Apps. I understand that we will be unable to access the Helios Who directory, the volunteer portal and Spring Celebration fun(d)raiser events.", +8/18/2026 13:05:10,freja.lindqvist@heliosschool.org,"Please remove all family names and emails from the Helios Community Apps. I understand that we will be unable to access the Helios Who directory, the volunteer portal and Spring Celebration fun(d)raiser events.", +8/18/2026 13:06:55,anders.lindqvist@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,"Home Address (if provided on Veracross), Adult Phone Number (if provided on Veracross)" +8/19/2026 8:15:33,asha.chandra@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,"Home Address (if provided on Veracross), Adult Phone Number (if provided on Veracross)" +8/19/2026 8:41:07,rohan.chandra@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,Adult Phone Number (if provided on Veracross) +8/19/2026 9:27:52,grace.kim@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,Home Address (if provided on Veracross) +8/19/2026 10:14:38,pat.nolan@heliosschool.org,I agree to have family names and emails in the Helios Community Apps,"Home Address (if provided on Veracross), Adult Phone Number (if provided on Veracross)" diff --git a/tools/deploy/main.go b/tools/deploy/main.go index b82e6e6..08a12ad 100644 --- a/tools/deploy/main.go +++ b/tools/deploy/main.go @@ -42,6 +42,10 @@ func main() { if sheet == "" { log.Fatal("[ERROR] DIRECTORY_SHEET is required") } + preferences := os.Getenv("PREFERENCES_SHEET") + if preferences == "" { + log.Fatal("[ERROR] PREFERENCES_SHEET is required") + } cmd := exec.Command("gcloud", "run", "deploy", service, "--image", image, @@ -54,7 +58,7 @@ func main() { "--concurrency", "250", "--no-cpu-throttling", "--use-http2", - "--set-env-vars", "DIRECTORY_SHEET="+sheet+",GOOGLE_CLIENT_ID="+clientID(), + "--set-env-vars", "DIRECTORY_SHEET="+sheet+",PREFERENCES_SHEET="+preferences+",GOOGLE_CLIENT_ID="+clientID(), "--set-secrets", secrets, "--quiet") cmd.Stdout = os.Stdout diff --git a/tools/loadcheck/main.go b/tools/loadcheck/main.go index ccb1b07..13be3c7 100644 --- a/tools/loadcheck/main.go +++ b/tools/loadcheck/main.go @@ -22,11 +22,12 @@ func (staticFiles) Has(key string) bool { func main() { sheet := flag.String("sheet", "", "spreadsheet id") + preferences := flag.String("preferences", "", "preferences spreadsheet id") flag.Parse() - if *sheet == "" { - log.Fatal("[ERROR] -sheet is required") + if *sheet == "" || *preferences == "" { + log.Fatal("[ERROR] -sheet and -preferences are required") } - source, err := data.NewSheet(map[string]string{"directory": *sheet}) + source, err := data.NewSheet(map[string]string{"directory": *sheet, "preferences": *preferences}) if err != nil { log.Fatalf("[ERROR] sheet source: %v", err) } @@ -51,7 +52,31 @@ func main() { } fmt.Printf("people: %d (students %d, parents %d, staff %d, new %d)\n", len(model.People), students, parents, staff, isNew) - fmt.Printf("families: %d\n", len(model.Families)) + byStatus := map[directory.OptStatus]int{} + addressMasked, phoneMasked := 0, 0 + for _, p := range model.People { + byStatus[p.OptStatus]++ + if p.AddressMasked { + addressMasked++ + } + if p.PhoneMasked { + phoneMasked++ + } + } + fmt.Printf("preferences: opt in %d, opt out %d, default %d (address masked %d, phone masked %d)\n", + byStatus[directory.OptIn], byStatus[directory.OptOut], byStatus[directory.OptDefault], + addressMasked, phoneMasked) + familyAddressMasked, familyPhoneMasked := 0, 0 + for _, f := range model.Families { + if f.AddressMasked { + familyAddressMasked++ + } + if f.PhoneMasked { + familyPhoneMasked++ + } + } + fmt.Printf("families: %d (address masked %d, phone masked %d)\n", + len(model.Families), familyAddressMasked, familyPhoneMasked) twoHousehold := map[string]int{} for _, f := range model.Families { for _, kid := range f.KidEmails { diff --git a/tools/startserver/main.go b/tools/startserver/main.go index d4cb086..d7501d4 100644 --- a/tools/startserver/main.go +++ b/tools/startserver/main.go @@ -23,6 +23,9 @@ func main() { if os.Getenv("DIRECTORY_SHEET") == "" { log.Fatal("[ERROR] DIRECTORY_SHEET is required") } + if os.Getenv("PREFERENCES_SHEET") == "" { + log.Fatal("[ERROR] PREFERENCES_SHEET is required") + } logFile, err := os.Create("/tmp/heliosian-server.log") if err != nil {