diff --git a/docs/data.md b/docs/data.md index 331fe30..fde4a08 100644 --- a/docs/data.md +++ b/docs/data.md @@ -9,7 +9,7 @@ The entities the directory serves, and how they are assembled. Structured data l | Veracross Import | import tool | The raw Veracross student export, rewritten wholesale by the import tool on each refresh. Read-only to the serving app; no local edit survives here. | | Name to Email | hand | Maps a student's name to their school email for import rows where the email cell is empty or wrong, so every person can be keyed by email downstream. | | 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 of self-service changes. | +| Change Log | app | Append-only audit trail mirroring the Overrides columns: one row per change, holding the previous values. | 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). @@ -67,6 +67,8 @@ Flagged rows must supply every field the model requires; unflagged rows can be a Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults. +Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history. + ## Media blobs Photos and pronunciation recordings are files in the media shared drive, named by convention: `-photo` and `-pronunciation` for people, `-photo` and `-pronunciation` for families. Presence means existence — no sheet cell records a filename — and freshness comes from the file's modified time. Uploads replace the file and archive the previous version; superseded versions stay in an archive folder. diff --git a/tools/createtabs/main.go b/tools/createtabs/main.go new file mode 100644 index 0000000..3675120 --- /dev/null +++ b/tools/createtabs/main.go @@ -0,0 +1,88 @@ +// Command createtabs creates the directory sheet's local-layer tabs with their header rows. +package main + +import ( + "context" + "flag" + "log" + "strings" + + "heliosian/internal/data" + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +var tabs = []struct { + title string + header []string +}{ + {"Name to Email", []string{"Name", "Email"}}, + {"Overrides", []string{ + "Email", "Added", + "Full Name", "Legal Name", "Preferred Name", + "Is Student", "Is Parent", "Is Staff", + "New to Helios", "Pronouns", "Facts", + "Grade", "Classroom", "Crew", + "Phone", "Job Title", "Department", "Grade Band", "Room Parent", + "Address", "Family Phone", "Family Photo Caption", + }}, + {"Change Log", []string{ + "Timestamp", "Actor", + "Email", "Added", + "Full Name", "Legal Name", "Preferred Name", + "Is Student", "Is Parent", "Is Staff", + "New to Helios", "Pronouns", "Facts", + "Grade", "Classroom", "Crew", + "Phone", "Job Title", "Department", "Grade Band", "Room Parent", + "Address", "Family Phone", "Family Photo Caption", + }}, +} + +func main() { + sheet := flag.String("sheet", "", "spreadsheet id") + flag.Parse() + if *sheet == "" { + log.Fatal("[ERROR] -sheet is required") + } + svc, err := sheets.NewService(context.Background(), + option.WithCredentialsFile(data.KeyFile), + option.WithScopes(sheets.SpreadsheetsScope)) + if err != nil { + log.Fatalf("[ERROR] create sheets client: %v", err) + } + meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(title))").Do() + if err != nil { + log.Fatalf("[ERROR] get spreadsheet: %v", err) + } + existing := map[string]bool{} + for _, s := range meta.Sheets { + existing[s.Properties.Title] = true + } + for _, t := range tabs { + if existing[t.title] { + log.Fatalf("[ERROR] tab %q already exists", t.title) + } + } + for _, t := range tabs { + _, err := svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{AddSheet: &sheets.AddSheetRequest{ + Properties: &sheets.SheetProperties{Title: t.title}, + }}}, + }).Do() + if err != nil { + log.Fatalf("[ERROR] create tab %q: %v", t.title, err) + } + values := make([]interface{}, len(t.header)) + for i, h := range t.header { + values[i] = h + } + quoted := "'" + strings.ReplaceAll(t.title, "'", "''") + "'" + _, err = svc.Spreadsheets.Values.Update(*sheet, quoted+"!1:1", &sheets.ValueRange{ + Values: [][]interface{}{values}, + }).ValueInputOption("RAW").Do() + if err != nil { + log.Fatalf("[ERROR] write header of %q: %v", t.title, err) + } + log.Printf("created tab %q with %d columns", t.title, len(t.header)) + } +} diff --git a/tools/oneoff/main.go b/tools/oneoff/main.go new file mode 100644 index 0000000..87c1b7b --- /dev/null +++ b/tools/oneoff/main.go @@ -0,0 +1,94 @@ +// Command oneoff drops the duplicated trailing import column and reshapes the Change Log header. +package main + +import ( + "context" + "flag" + "fmt" + "log" + + "heliosian/internal/data" + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +var changeLogHeader = []string{ + "Timestamp", "Actor", + "Email", "Added", + "Full Name", "Legal Name", "Preferred Name", + "Is Student", "Is Parent", "Is Staff", + "New to Helios", "Pronouns", "Facts", + "Grade", "Classroom", "Crew", + "Phone", "Job Title", "Department", "Grade Band", "Room Parent", + "Address", "Family Phone", "Family Photo Caption", +} + +func main() { + sheet := flag.String("sheet", "", "spreadsheet id") + flag.Parse() + if *sheet == "" { + log.Fatal("[ERROR] -sheet is required") + } + svc, err := sheets.NewService(context.Background(), + option.WithCredentialsFile(data.KeyFile), + option.WithScopes(sheets.SpreadsheetsScope)) + if err != nil { + log.Fatalf("[ERROR] create sheets client: %v", err) + } + + meta, err := svc.Spreadsheets.Get(*sheet).Fields("sheets(properties(sheetId,title))").Do() + if err != nil { + log.Fatalf("[ERROR] get spreadsheet: %v", err) + } + importID := int64(-1) + for _, s := range meta.Sheets { + if s.Properties.Title == "Veracross Import" { + importID = s.Properties.SheetId + } + } + if importID < 0 { + log.Fatal("[ERROR] no Veracross Import tab") + } + + resp, err := svc.Spreadsheets.Values.Get(*sheet, "'Veracross Import'!AD1:AD1000").Do() + if err != nil { + log.Fatalf("[ERROR] read column AD: %v", err) + } + for i, row := range resp.Values { + if i == 0 { + if len(row) == 0 || fmt.Sprint(row[0]) != "household_2_person_2_phone_business" { + log.Fatalf("[ERROR] column AD header is %v, not the expected duplicate", row) + } + continue + } + if len(row) > 0 && fmt.Sprint(row[0]) != "" { + log.Fatalf("[ERROR] column AD row %d has data %q; not deleting", i+1, row[0]) + } + } + _, err = svc.Spreadsheets.BatchUpdate(*sheet, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{DeleteDimension: &sheets.DeleteDimensionRequest{ + Range: &sheets.DimensionRange{ + SheetId: importID, + Dimension: "COLUMNS", + StartIndex: 29, + EndIndex: 30, + }, + }}}, + }).Do() + if err != nil { + log.Fatalf("[ERROR] delete column AD: %v", err) + } + log.Print("deleted duplicate import column AD") + + values := make([]interface{}, len(changeLogHeader)) + for i, h := range changeLogHeader { + values[i] = h + } + _, err = svc.Spreadsheets.Values.Update(*sheet, "'Change Log'!1:1", &sheets.ValueRange{ + Values: [][]interface{}{values}, + }).ValueInputOption("RAW").Do() + if err != nil { + log.Fatalf("[ERROR] rewrite change log header: %v", err) + } + log.Printf("rewrote Change Log header with %d columns", len(changeLogHeader)) +}