feat: auto-assign empty duty weeks via service-account sheet writes
Migrate Sheets auth from an API key to a service-account credential
(GOOGLE_CREDENTIALS_FILE) with the read+write spreadsheets scope, add a
WriteSheet primitive, and use it to actively manage empty duty weeks in
the weekly reminder.
Each fire now handles a fixed this-week/next-week model (replacing the
DUTY_AHEAD_DAYS announce):
- this week empty -> pick the two people with the fewest duties (random
tie-break), write their names into cols B/C, and announce
- this week filled -> announce who is on duty
- next week empty -> ask people to volunteer before it is auto-assigned
Col G ("Liczba dyżurów") is a live formula, so names-only writes keep the
fewest-duties selection fair across weeks. Selection, row tracking, range
building, and every fire() branch are unit-tested; live tests for sheet
read/write are env-gated.
This commit is contained in:
parent
825331dce5
commit
6573144b14
|
|
@ -1,6 +1,7 @@
|
|||
# Secrets and local state — never bake into the build context/image.
|
||||
.env
|
||||
rota.json
|
||||
hackerspace-492302-a63993a1af9d.json
|
||||
|
||||
# VCS and tooling.
|
||||
.git
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
.env
|
||||
rota.json
|
||||
hackerspace-492302-a63993a1af9d.json
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -6,7 +6,8 @@ Backend read the schedule from a Google sheet, store it in local JSON file.
|
|||
|
||||
## Running
|
||||
|
||||
Requires `GOOGLE_API_KEY`, `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
|
||||
Requires `GOOGLE_CREDENTIALS_FILE` (path to a Google service-account key,
|
||||
shared as an editor on the sheet), `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
|
||||
environment or a `.env` file (see `service.go`/`sheet.go` for optional
|
||||
`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
|
||||
|
||||
|
|
@ -52,7 +53,14 @@ across restarts.
|
|||
|
||||
If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat.
|
||||
Schedule (all optional, with defaults): `REMINDER_WEEKDAY` (default `Monday`),
|
||||
`REMINDER_HOUR` (default `9`, 0–23), `DUTY_AHEAD_DAYS` (default `14`). The hour
|
||||
is interpreted in Europe/Warsaw time. The reminder announces who is on duty
|
||||
`DUTY_AHEAD_DAYS` from the fire time — e.g. every Monday 09:00, who cleans two
|
||||
weeks out. Leave `GROUP_CHAT_ID` unset to disable reminders.
|
||||
`REMINDER_HOUR` (default `9`, 0–23), interpreted in Europe/Warsaw time.
|
||||
|
||||
On each fire the bot handles two weeks relative to the fire time:
|
||||
|
||||
- **This week** — announces who is on duty. If both slots are empty, it picks
|
||||
the two people with the fewest duties (random tie-break), writes their names
|
||||
into the sheet, and announces the assignment.
|
||||
- **Next week** — if both slots are still empty, it asks people to volunteer
|
||||
before it gets assigned automatically the following week.
|
||||
|
||||
Leave `GROUP_CHAT_ID` unset to disable reminders.
|
||||
|
|
|
|||
|
|
@ -65,10 +65,11 @@ func run() error {
|
|||
|
||||
if r := cfg.Reminder; r != nil {
|
||||
send := dyzurbot.BotSendFunc(b, r.ChatID)
|
||||
go dyzurbot.NewReminder(svc.Store, send, *r).Run(ctx)
|
||||
assign := dyzurbot.SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange)
|
||||
go dyzurbot.NewReminder(svc.Store, send, assign, *r).Run(ctx)
|
||||
slog.Info("reminders on",
|
||||
"weekday", r.Weekday, "hour", r.Hour, "tz", r.Loc.String(),
|
||||
"days_ahead", r.AheadDays, "chat", r.ChatID)
|
||||
"chat", r.ChatID)
|
||||
} else {
|
||||
slog.Info("reminders disabled (no GROUP_CHAT_ID)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
package dyzurbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"google.golang.org/api/sheets/v4"
|
||||
)
|
||||
|
||||
// These are manual smoke tests that hit the real Google Sheet using the
|
||||
// credential in .env (GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID). They are
|
||||
// skipped unless their gate env var is set, so the normal `go test ./...` run
|
||||
// stays offline and side-effect-free.
|
||||
|
||||
// TestLiveAuthRead proves the service-account credential loads and the sheet is
|
||||
// reachable. Read-only. Gate: DYZUR_LIVE_AUTH=1.
|
||||
func TestLiveAuthRead(t *testing.T) {
|
||||
if os.Getenv("DYZUR_LIVE_AUTH") != "1" {
|
||||
t.Skip("set DYZUR_LIVE_AUTH=1 to run")
|
||||
}
|
||||
_ = godotenv.Load()
|
||||
id := os.Getenv("GOOGLE_SHEETS_ID")
|
||||
rows, err := ReadSheet(context.Background(), id, resolveRange("", time.Now()))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSheet: %v", err)
|
||||
}
|
||||
t.Logf("OK: fetched %d rows", len(rows))
|
||||
}
|
||||
|
||||
// TestLiveWriteRoundTrip proves the spreadsheets (read+write) scope actually
|
||||
// grants writes: it creates a throwaway tab, writes a marker via WriteSheet,
|
||||
// reads it back, and deletes the tab — so no real rota data is touched. Gate:
|
||||
// DYZUR_LIVE_WRITE=1.
|
||||
func TestLiveWriteRoundTrip(t *testing.T) {
|
||||
if os.Getenv("DYZUR_LIVE_WRITE") != "1" {
|
||||
t.Skip("set DYZUR_LIVE_WRITE=1 to run")
|
||||
}
|
||||
_ = godotenv.Load()
|
||||
ctx := context.Background()
|
||||
id := os.Getenv("GOOGLE_SHEETS_ID")
|
||||
|
||||
srv, err := newSheetsService(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("newSheetsService: %v", err)
|
||||
}
|
||||
|
||||
// Create an isolated throwaway tab; the timestamp keeps the title unique.
|
||||
title := "scratch-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
addResp, err := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
|
||||
Requests: []*sheets.Request{{
|
||||
AddSheet: &sheets.AddSheetRequest{
|
||||
Properties: &sheets.SheetProperties{Title: title},
|
||||
},
|
||||
}},
|
||||
}).Context(ctx).Do()
|
||||
if err != nil {
|
||||
t.Fatalf("create scratch tab: %v", err)
|
||||
}
|
||||
sheetID := addResp.Replies[0].AddSheet.Properties.SheetId
|
||||
|
||||
// Always remove the throwaway tab, even if the assertions below fail.
|
||||
defer func() {
|
||||
_, derr := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{
|
||||
Requests: []*sheets.Request{{
|
||||
DeleteSheet: &sheets.DeleteSheetRequest{SheetId: sheetID},
|
||||
}},
|
||||
}).Context(ctx).Do()
|
||||
if derr != nil {
|
||||
t.Errorf("cleanup: delete scratch tab %q: %v", title, derr)
|
||||
}
|
||||
}()
|
||||
|
||||
const want = "dyzur-write-probe"
|
||||
writeRange := title + "!A1"
|
||||
if err := WriteSheet(ctx, id, writeRange, [][]interface{}{{want}}); err != nil {
|
||||
t.Fatalf("WriteSheet: %v", err)
|
||||
}
|
||||
|
||||
rows, err := ReadSheet(ctx, id, writeRange)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSheet back: %v", err)
|
||||
}
|
||||
if len(rows) == 0 || len(rows[0]) == 0 || rows[0][0] != want {
|
||||
t.Fatalf("round-trip mismatch: got %v, want %q", rows, want)
|
||||
}
|
||||
t.Logf("OK: wrote and read back %q from throwaway tab %q", want, title)
|
||||
}
|
||||
144
scheduler.go
144
scheduler.go
|
|
@ -4,6 +4,8 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -13,7 +15,6 @@ type ReminderConfig struct {
|
|||
ChatID int64 // Telegram chat to post to
|
||||
Weekday time.Weekday // day of week to fire
|
||||
Hour int // hour of day (0–23) to fire
|
||||
AheadDays int // announce the duty this many days from the fire time
|
||||
Loc *time.Location // timezone the schedule is expressed in
|
||||
}
|
||||
|
||||
|
|
@ -21,23 +22,39 @@ type ReminderConfig struct {
|
|||
// can be tested without a live bot (see BotSendFunc for the real wiring).
|
||||
type SendFunc func(ctx context.Context, text string) error
|
||||
|
||||
// Reminder posts a duty reminder to a chat on a weekly schedule.
|
||||
// AssignFunc persists a duty assignment: it writes p1/p2 into week's cells. It
|
||||
// abstracts the sheet write so Reminder can be tested without Google (see
|
||||
// SheetAssigner for the real wiring).
|
||||
type AssignFunc func(ctx context.Context, week DutyWeek, p1, p2 string) error
|
||||
|
||||
// Reminder posts a duty reminder to a chat on a weekly schedule, and fills empty
|
||||
// duty weeks by assigning people when nobody has volunteered.
|
||||
type Reminder struct {
|
||||
store *Store
|
||||
send SendFunc
|
||||
assign AssignFunc
|
||||
cfg ReminderConfig
|
||||
now func() time.Time
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewReminder wires a reminder to the rota cache and a send function.
|
||||
func NewReminder(store *Store, send SendFunc, cfg ReminderConfig) *Reminder {
|
||||
return &Reminder{store: store, send: send, cfg: cfg, now: time.Now}
|
||||
// NewReminder wires a reminder to the rota cache, a send function, and an
|
||||
// assigner (used to persist auto-assigned duty weeks).
|
||||
func NewReminder(store *Store, send SendFunc, assign AssignFunc, cfg ReminderConfig) *Reminder {
|
||||
return &Reminder{
|
||||
store: store,
|
||||
send: send,
|
||||
assign: assign,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
// #nosec G404 -- duty selection is fairness, not security; a weak RNG is fine.
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// Run fires the reminder on schedule until ctx is canceled. Each iteration
|
||||
// recomputes the next fire time, sleeps until then, and on fire looks up the
|
||||
// duty AheadDays out and posts it (skipping weeks with no data). Send failures
|
||||
// are logged and the loop continues.
|
||||
// recomputes the next fire time, sleeps until then, and on fire handles this and
|
||||
// next week (see fire). Send failures are logged and the loop continues.
|
||||
func (r *Reminder) Run(ctx context.Context) {
|
||||
for {
|
||||
now := r.now()
|
||||
|
|
@ -53,18 +70,66 @@ func (r *Reminder) Run(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// fire performs one reminder: snapshot the cache, format, and send.
|
||||
// fire performs one weekly run: it finalizes this week (announcing who is on
|
||||
// duty, or auto-assigning when nobody volunteered) and warns when next week is
|
||||
// still unassigned. Dates are computed in the schedule's zone so the calendar
|
||||
// day matches the wall-clock fire day (consistent with nextFire); reading them
|
||||
// in server-local time could land on the prior day on a UTC host.
|
||||
func (r *Reminder) fire(ctx context.Context) {
|
||||
rota, _ := r.store.Snapshot()
|
||||
// Compute the target in the schedule's zone so the calendar date matches
|
||||
// the wall-clock fire day (consistent with nextFire); reading it in
|
||||
// server-local time could land on the prior day on a UTC host.
|
||||
target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays)
|
||||
text, ok := reminderText(rota, target)
|
||||
now := r.now().In(r.cfg.Loc)
|
||||
handles := handlesByName(rota.People)
|
||||
|
||||
r.handleThisWeek(ctx, rota, now, handles)
|
||||
r.handleNextWeek(ctx, rota, now)
|
||||
}
|
||||
|
||||
// handleThisWeek announces the current duty, or auto-assigns it when both slots
|
||||
// are empty.
|
||||
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, handles map[string]string) {
|
||||
week, ok := currentDuty(rota, now)
|
||||
if !ok {
|
||||
slog.Info("reminder: no duty week, skipping", "target", target.Format("2006-01-02"))
|
||||
slog.Info("reminder: no week row for this week, skipping", "date", now.Format("2006-01-02"))
|
||||
return
|
||||
}
|
||||
|
||||
if week.Person1 != "" || week.Person2 != "" {
|
||||
if text, ok := reminderText(rota, now); ok {
|
||||
r.sendLogged(ctx, text)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Both slots empty: assign the two people with the fewest duties.
|
||||
p1, p2, ok := chooseTwo(rota.People, r.rng)
|
||||
if !ok {
|
||||
slog.Info("reminder: this week empty but fewer than 2 people, skipping assignment")
|
||||
return
|
||||
}
|
||||
if week.Row == 0 {
|
||||
slog.Warn("reminder: this week empty but sheet row unknown, cannot assign",
|
||||
"week", week.WeekStart.Format("2006-01-02"))
|
||||
return
|
||||
}
|
||||
if err := r.assign(ctx, week, p1, p2); err != nil {
|
||||
slog.Error("reminder: assign write failed", "err", err)
|
||||
return
|
||||
}
|
||||
r.sendLogged(ctx, assignedText(week, p1, p2, handles))
|
||||
}
|
||||
|
||||
// handleNextWeek warns when next week is still completely unassigned.
|
||||
func (r *Reminder) handleNextWeek(ctx context.Context, rota Rota, now time.Time) {
|
||||
week, ok := currentDuty(rota, now.AddDate(0, 0, 7))
|
||||
if !ok || week.Person1 != "" || week.Person2 != "" {
|
||||
return
|
||||
}
|
||||
r.sendLogged(ctx, nextWeekWarningText(week))
|
||||
}
|
||||
|
||||
// sendLogged sends text, logging (not propagating) any failure so one bad send
|
||||
// does not abort the rest of the run.
|
||||
func (r *Reminder) sendLogged(ctx context.Context, text string) {
|
||||
if err := r.send(ctx, text); err != nil {
|
||||
slog.Error("reminder send failed", "err", err)
|
||||
}
|
||||
|
|
@ -114,8 +179,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) {
|
|||
body = "dyżur sprzątają " + strings.Join(people, " i ")
|
||||
}
|
||||
|
||||
tail := "."
|
||||
if len(people) > 0 {
|
||||
tail = ". Proszę pamiętać o sprzątaniu."
|
||||
}
|
||||
return "Przypomnienie: w tygodniu od " +
|
||||
week.WeekStart.Format("2006-01-02") + " " + body + ".", true
|
||||
week.WeekStart.Format("2006-01-02") + " " + body + tail, true
|
||||
}
|
||||
|
||||
// assignedText announces an auto-assigned duty week (both people just chosen).
|
||||
func assignedText(week DutyWeek, p1, p2 string, handles map[string]string) string {
|
||||
return "Nikt nie zgłosił się na dyżur w tym tygodniu (od " +
|
||||
week.WeekStart.Format("2006-01-02") + "), więc został przydzielony losowo: " +
|
||||
mention(p1, handles) + " i " + mention(p2, handles) +
|
||||
". Z góry dziękujemy za sprzątanie!"
|
||||
}
|
||||
|
||||
// nextWeekWarningText asks people to volunteer for an as-yet-unassigned next
|
||||
// week before it is filled automatically.
|
||||
func nextWeekWarningText(week DutyWeek) string {
|
||||
return "Dyżur na przyszły tydzień (od " +
|
||||
week.WeekStart.Format("2006-01-02") +
|
||||
") jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||
}
|
||||
|
||||
// handlesByName indexes the roster's Telegram handles by person name, so a
|
||||
|
|
@ -140,6 +225,33 @@ func mention(name string, handles map[string]string) string {
|
|||
return "@" + strings.TrimPrefix(h, "@")
|
||||
}
|
||||
|
||||
// chooseTwo picks the two people with the fewest recorded duties, returning the
|
||||
// lower-count one first. Equal counts are broken randomly (via rng) so no one is
|
||||
// systematically favored among ties. Entries with a blank name are ignored;
|
||||
// ok is false when fewer than two eligible people remain.
|
||||
func chooseTwo(people []Person, rng *rand.Rand) (p1, p2 string, ok bool) {
|
||||
eligible := make([]Person, 0, len(people))
|
||||
for _, p := range people {
|
||||
if strings.TrimSpace(p.Name) != "" {
|
||||
eligible = append(eligible, p)
|
||||
}
|
||||
}
|
||||
if len(eligible) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Shuffle first, then stable-sort by count: equal counts keep the shuffled
|
||||
// (random) order, giving a fair tie-break.
|
||||
rng.Shuffle(len(eligible), func(i, j int) {
|
||||
eligible[i], eligible[j] = eligible[j], eligible[i]
|
||||
})
|
||||
sort.SliceStable(eligible, func(i, j int) bool {
|
||||
return eligible[i].DutyCount < eligible[j].DutyCount
|
||||
})
|
||||
|
||||
return eligible[0].Name, eligible[1].Name, true
|
||||
}
|
||||
|
||||
// parseWeekday maps an English weekday name (case-insensitive) to time.Weekday.
|
||||
func parseWeekday(s string) (time.Weekday, error) {
|
||||
days := map[string]time.Weekday{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package dyzurbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLiveReminderFire performs ONE real reminder fire against a live Telegram
|
||||
// chat and the live Google Sheet, exercising the full scheduler send path:
|
||||
// Syncer.Sync -> Store.Snapshot -> reminderText -> mention -> BotSendFunc.
|
||||
//
|
||||
// It is a manual smoke test, skipped unless DYZUR_LIVE_FIRE=1, and needs the same
|
||||
// env the binary uses: BOT_TOKEN, GROUP_CHAT_ID, GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID
|
||||
// (and ROTA_CACHE_PATH for the cache file).
|
||||
//
|
||||
// WARNING — this MUTATES the sheet. fire() announces the current week and, when
|
||||
// that week is empty, auto-assigns two people and WRITES their names into the
|
||||
// rota. Point GROUP_CHAT_ID at a SANDBOX chat AND GOOGLE_SHEETS_ID at a COPY of
|
||||
// the sheet — otherwise this pings real members and edits the production rota.
|
||||
func TestLiveReminderFire(t *testing.T) {
|
||||
if os.Getenv("DYZUR_LIVE_FIRE") != "1" {
|
||||
t.Skip("set DYZUR_LIVE_FIRE=1 to run the live reminder smoke test")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig: %v", err)
|
||||
}
|
||||
if cfg.Reminder == nil {
|
||||
t.Fatal("Reminder config is nil — set GROUP_CHAT_ID")
|
||||
}
|
||||
|
||||
// Synchronous sync so the store is populated before we fire (Service.Start
|
||||
// would run the first sync in the background, racing the fire below).
|
||||
svc := NewService(cfg)
|
||||
if err := svc.Syncer.Sync(ctx); err != nil {
|
||||
t.Fatalf("sheet sync: %v", err)
|
||||
}
|
||||
|
||||
token := os.Getenv("BOT_TOKEN")
|
||||
if token == "" {
|
||||
t.Fatal("missing BOT_TOKEN")
|
||||
}
|
||||
b, err := NewBot(token, svc)
|
||||
if err != nil {
|
||||
t.Fatalf("NewBot: %v", err)
|
||||
}
|
||||
|
||||
rc := *cfg.Reminder
|
||||
assign := SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange)
|
||||
|
||||
NewReminder(svc.Store, BotSendFunc(b, rc.ChatID), assign, rc).fire(ctx)
|
||||
t.Logf("fired reminder to chat %d for the current week — verify it landed in the chat", rc.ChatID)
|
||||
}
|
||||
|
|
@ -1,10 +1,216 @@
|
|||
package dyzurbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type assignCall struct {
|
||||
week DutyWeek
|
||||
p1, p2 string
|
||||
}
|
||||
|
||||
// fireHarness builds a Reminder over a fixed rota/now with capturing send and
|
||||
// assign funcs, so fire()'s branches can be asserted without Telegram or Google.
|
||||
type fireHarness struct {
|
||||
sends []string
|
||||
assigns []assignCall
|
||||
assignFn AssignFunc
|
||||
}
|
||||
|
||||
func newFireHarness(t *testing.T, rota Rota, now time.Time, seed int64) (*Reminder, *fireHarness) {
|
||||
t.Helper()
|
||||
h := &fireHarness{}
|
||||
send := func(_ context.Context, text string) error {
|
||||
h.sends = append(h.sends, text)
|
||||
return nil
|
||||
}
|
||||
assign := func(ctx context.Context, week DutyWeek, p1, p2 string) error {
|
||||
h.assigns = append(h.assigns, assignCall{week, p1, p2})
|
||||
if h.assignFn != nil {
|
||||
return h.assignFn(ctx, week, p1, p2)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
r := &Reminder{
|
||||
store: &Store{rota: rota},
|
||||
send: send,
|
||||
assign: assign,
|
||||
cfg: ReminderConfig{Loc: warsaw(t)},
|
||||
now: func() time.Time { return now },
|
||||
rng: rand.New(rand.NewSource(seed)),
|
||||
}
|
||||
return r, h
|
||||
}
|
||||
|
||||
func TestFire(t *testing.T) {
|
||||
loc := warsaw(t)
|
||||
mon := time.Date(2026, 6, 15, 9, 0, 0, 0, loc) // Mon; this week starts 2026-06-15
|
||||
people := []Person{
|
||||
{Name: "Ala", DutyCount: 1},
|
||||
{Name: "Bartek", DutyCount: 2},
|
||||
{Name: "Cela", DutyCount: 3},
|
||||
}
|
||||
|
||||
t.Run("this week empty -> assign two lowest and announce", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
Weeks: []DutyWeek{
|
||||
{WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty
|
||||
{WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}, // next: filled
|
||||
},
|
||||
People: people,
|
||||
}
|
||||
r, h := newFireHarness(t, rota, mon, 1)
|
||||
r.fire(context.Background())
|
||||
|
||||
if len(h.assigns) != 1 {
|
||||
t.Fatalf("assigns = %d, want 1", len(h.assigns))
|
||||
}
|
||||
a := h.assigns[0]
|
||||
if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 5 {
|
||||
t.Errorf("assign = %+v, want Ala/Bartek on row 5", a)
|
||||
}
|
||||
want := "Nikt nie zgłosił się na dyżur w tym tygodniu (od 2026-06-15), więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!"
|
||||
if len(h.sends) != 1 || h.sends[0] != want {
|
||||
t.Errorf("sends = %q, want [%q]", h.sends, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this week filled, next week empty -> reminder + warning", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
Weeks: []DutyWeek{
|
||||
{WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5},
|
||||
{WeekStart: day(t, "2026-06-22"), Row: 6}, // next: empty
|
||||
},
|
||||
People: people,
|
||||
}
|
||||
r, h := newFireHarness(t, rota, mon, 1)
|
||||
r.fire(context.Background())
|
||||
|
||||
if len(h.assigns) != 0 {
|
||||
t.Errorf("assigns = %d, want 0 (this week already filled)", len(h.assigns))
|
||||
}
|
||||
wantReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur sprzątają Jan i Anna. Proszę pamiętać o sprzątaniu."
|
||||
wantWarning := "Dyżur na przyszły tydzień (od 2026-06-22) jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
|
||||
if len(h.sends) != 2 || h.sends[0] != wantReminder || h.sends[1] != wantWarning {
|
||||
t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantReminder, wantWarning)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this week empty but fewer than 2 people -> no assign, no send", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||
People: []Person{{Name: "Ala", DutyCount: 1}},
|
||||
}
|
||||
r, h := newFireHarness(t, rota, mon, 1)
|
||||
r.fire(context.Background())
|
||||
|
||||
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
||||
t.Errorf("assigns=%d sends=%q, want 0 and none", len(h.assigns), h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("this week empty but row unknown -> no assign, no send", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 0}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||
People: people,
|
||||
}
|
||||
r, h := newFireHarness(t, rota, mon, 1)
|
||||
r.fire(context.Background())
|
||||
|
||||
if len(h.assigns) != 0 || len(h.sends) != 0 {
|
||||
t.Errorf("assigns=%d sends=%q, want 0 and none (unwritable row)", len(h.assigns), h.sends)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("assign write fails -> no announcement", func(t *testing.T) {
|
||||
rota := Rota{
|
||||
Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}},
|
||||
People: people,
|
||||
}
|
||||
r, h := newFireHarness(t, rota, mon, 1)
|
||||
h.assignFn = func(context.Context, DutyWeek, string, string) error { return errors.New("boom") }
|
||||
r.fire(context.Background())
|
||||
|
||||
if len(h.assigns) != 1 {
|
||||
t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns))
|
||||
}
|
||||
if len(h.sends) != 0 {
|
||||
t.Errorf("sends = %q, want none (write failed)", h.sends)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChooseTwo(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
|
||||
t.Run("picks two lowest counts, lowest first", func(t *testing.T) {
|
||||
people := []Person{
|
||||
{Name: "Ala", DutyCount: 3},
|
||||
{Name: "Bartek", DutyCount: 1},
|
||||
{Name: "Cela", DutyCount: 2},
|
||||
}
|
||||
p1, p2, ok := chooseTwo(people, rng)
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
if p1 != "Bartek" || p2 != "Cela" {
|
||||
t.Errorf("got (%q, %q), want (Bartek, Cela)", p1, p2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores blank-name entries", func(t *testing.T) {
|
||||
people := []Person{
|
||||
{Name: "", DutyCount: 0},
|
||||
{Name: "Ala", DutyCount: 5},
|
||||
{Name: "Bartek", DutyCount: 6},
|
||||
}
|
||||
p1, p2, ok := chooseTwo(people, rng)
|
||||
if !ok || p1 != "Ala" || p2 != "Bartek" {
|
||||
t.Errorf("got (%q, %q, %v), want (Ala, Bartek, true)", p1, p2, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fewer than two eligible -> ok=false", func(t *testing.T) {
|
||||
if _, _, ok := chooseTwo([]Person{{Name: "Ala"}}, rng); ok {
|
||||
t.Error("one person: ok = true, want false")
|
||||
}
|
||||
if _, _, ok := chooseTwo(nil, rng); ok {
|
||||
t.Error("no people: ok = true, want false")
|
||||
}
|
||||
if _, _, ok := chooseTwo([]Person{{Name: ""}, {Name: ""}}, rng); ok {
|
||||
t.Error("only blank names: ok = true, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equal counts: random tie-break, distinct and deterministic per seed", func(t *testing.T) {
|
||||
people := []Person{
|
||||
{Name: "Ala", DutyCount: 1},
|
||||
{Name: "Bartek", DutyCount: 1},
|
||||
{Name: "Cela", DutyCount: 1},
|
||||
}
|
||||
p1, p2, ok := chooseTwo(people, rand.New(rand.NewSource(42)))
|
||||
if !ok {
|
||||
t.Fatal("ok = false, want true")
|
||||
}
|
||||
if p1 == p2 {
|
||||
t.Errorf("chose the same person twice: %q", p1)
|
||||
}
|
||||
names := map[string]bool{"Ala": true, "Bartek": true, "Cela": true}
|
||||
if !names[p1] || !names[p2] {
|
||||
t.Errorf("got (%q, %q), both must be from the roster", p1, p2)
|
||||
}
|
||||
// Same seed reproduces the same choice.
|
||||
q1, q2, _ := chooseTwo(people, rand.New(rand.NewSource(42)))
|
||||
if q1 != p1 || q2 != p2 {
|
||||
t.Errorf("not deterministic for a seed: (%q,%q) vs (%q,%q)", p1, p2, q1, q2)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func warsaw(t *testing.T) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Europe/Warsaw")
|
||||
|
|
@ -99,12 +305,12 @@ func TestReminderText(t *testing.T) {
|
|||
{
|
||||
"both people",
|
||||
rota("Ala", "Bartek"), target, true,
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
{
|
||||
"one person",
|
||||
rota("Ala", ""), target, true,
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
{
|
||||
"nobody assigned",
|
||||
|
|
@ -149,22 +355,22 @@ func TestReminderTextMentions(t *testing.T) {
|
|||
{
|
||||
"both have handles",
|
||||
rota("Ala", "Bartek"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
{
|
||||
"one handle, one missing -> mixed",
|
||||
rota("Ala", "Cela"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
{
|
||||
"name not in people list -> plain name",
|
||||
rota("Ala", "Zenon"),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
{
|
||||
"single person with handle",
|
||||
rota("Bartek", ""),
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart.",
|
||||
"Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart. Proszę pamiętać o sprzątaniu.",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
|
|
|
|||
42
service.go
42
service.go
|
|
@ -7,6 +7,7 @@ import (
|
|||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
|
@ -44,6 +45,17 @@ func LoadConfig() (Config, error) {
|
|||
return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_ID in environment/.env")
|
||||
}
|
||||
|
||||
// Fail fast at startup if the service-account key is missing or unreadable,
|
||||
// rather than on the first sheet fetch minutes later. ReadSheet consumes the
|
||||
// path directly from the environment.
|
||||
credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE")
|
||||
if credPath == "" {
|
||||
return Config{}, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env")
|
||||
}
|
||||
if _, err := os.Stat(credPath); err != nil { // #nosec G703 -- credPath is operator-controlled config (env/.env)
|
||||
return Config{}, fmt.Errorf("GOOGLE_CREDENTIALS_FILE %q: %w", credPath, err)
|
||||
}
|
||||
|
||||
raw := getenvDefault("SYNC_INTERVAL", "30m")
|
||||
interval, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
|
|
@ -90,15 +102,6 @@ func loadReminderConfig() (*ReminderConfig, error) {
|
|||
return nil, fmt.Errorf("REMINDER_HOUR must be 0–23, got %d", hour)
|
||||
}
|
||||
|
||||
aheadRaw := getenvDefault("DUTY_AHEAD_DAYS", "14")
|
||||
ahead, err := strconv.Atoi(aheadRaw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid DUTY_AHEAD_DAYS %q: %w", aheadRaw, err)
|
||||
}
|
||||
if ahead < 0 {
|
||||
return nil, fmt.Errorf("DUTY_AHEAD_DAYS must be >= 0, got %d", ahead)
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(reminderTimezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load timezone %q: %w", reminderTimezone, err)
|
||||
|
|
@ -108,7 +111,6 @@ func loadReminderConfig() (*ReminderConfig, error) {
|
|||
ChatID: chatID,
|
||||
Weekday: weekday,
|
||||
Hour: hour,
|
||||
AheadDays: ahead,
|
||||
Loc: loc,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -224,6 +226,26 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// SheetAssigner returns an AssignFunc that writes a week's two names into its
|
||||
// B:C cells on the same tab the rota is read from. sheetRange mirrors the
|
||||
// fetcher's range so the tab (year or explicit) matches.
|
||||
func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc {
|
||||
return func(ctx context.Context, week DutyWeek, p1, p2 string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
rng := assignRange(sheetRange, time.Now(), week.Row)
|
||||
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
|
||||
}
|
||||
}
|
||||
|
||||
// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are
|
||||
// columns B and C). It reuses the tab from resolveRange so the year rollover and
|
||||
// any explicit range are honored identically to reads.
|
||||
func assignRange(sheetRange string, now time.Time, row int) string {
|
||||
tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!")
|
||||
return fmt.Sprintf("%s!B%d:C%d", tab, row, row)
|
||||
}
|
||||
|
||||
// resolveRange returns the explicit range when set, otherwise the default that
|
||||
// targets the current year's tab (sheets are named per year, e.g. "2026").
|
||||
// Resolving against now (not a startup-captured value) is what lets the range
|
||||
|
|
|
|||
|
|
@ -3,16 +3,31 @@ package dyzurbot
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setCredFile points GOOGLE_CREDENTIALS_FILE at an existing temp file so
|
||||
// LoadConfig's startup validation passes without depending on the developer's
|
||||
// local (gitignored) .env. The contents are irrelevant: LoadConfig only stats
|
||||
// the path; the file is parsed later, by the Sheets client.
|
||||
func setCredFile(t *testing.T) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "sa.json")
|
||||
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatalf("write temp cred file: %v", err)
|
||||
}
|
||||
t.Setenv("GOOGLE_CREDENTIALS_FILE", path)
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||
t.Setenv("SHEET_RANGE", "")
|
||||
t.Setenv("ROTA_CACHE_PATH", "")
|
||||
t.Setenv("SYNC_INTERVAL", "")
|
||||
setCredFile(t)
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
|
|
@ -41,8 +56,25 @@ func TestLoadConfigRequiresSpreadsheetID(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRequiresCredentialsFile(t *testing.T) {
|
||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||
t.Setenv("GOOGLE_CREDENTIALS_FILE", "")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsMissingCredentialsFile(t *testing.T) {
|
||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||
t.Setenv("GOOGLE_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "does-not-exist.json"))
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE points to a nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsBadInterval(t *testing.T) {
|
||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||
setCredFile(t)
|
||||
t.Setenv("SYNC_INTERVAL", "not-a-duration")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected error for unparseable SYNC_INTERVAL")
|
||||
|
|
@ -51,6 +83,7 @@ func TestLoadConfigRejectsBadInterval(t *testing.T) {
|
|||
|
||||
func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) {
|
||||
t.Setenv("GOOGLE_SHEETS_ID", "sheet-123")
|
||||
setCredFile(t)
|
||||
t.Setenv("SYNC_INTERVAL", "0s")
|
||||
if _, err := LoadConfig(); err == nil {
|
||||
t.Fatal("expected error for non-positive SYNC_INTERVAL")
|
||||
|
|
@ -74,7 +107,6 @@ func TestLoadReminderConfigParses(t *testing.T) {
|
|||
t.Setenv("GROUP_CHAT_ID", "-5572351978")
|
||||
t.Setenv("REMINDER_WEEKDAY", "Friday")
|
||||
t.Setenv("REMINDER_HOUR", "18")
|
||||
t.Setenv("DUTY_AHEAD_DAYS", "7")
|
||||
|
||||
r, err := loadReminderConfig()
|
||||
if err != nil {
|
||||
|
|
@ -86,8 +118,8 @@ func TestLoadReminderConfigParses(t *testing.T) {
|
|||
if r.ChatID != -5572351978 {
|
||||
t.Errorf("ChatID = %d, want -5572351978", r.ChatID)
|
||||
}
|
||||
if r.Weekday != time.Friday || r.Hour != 18 || r.AheadDays != 7 {
|
||||
t.Errorf("got %v %d ahead=%d, want Friday 18 ahead=7", r.Weekday, r.Hour, r.AheadDays)
|
||||
if r.Weekday != time.Friday || r.Hour != 18 {
|
||||
t.Errorf("got %v %d, want Friday 18", r.Weekday, r.Hour)
|
||||
}
|
||||
if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" {
|
||||
t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc)
|
||||
|
|
@ -98,14 +130,13 @@ func TestLoadReminderConfigDefaults(t *testing.T) {
|
|||
t.Setenv("GROUP_CHAT_ID", "42")
|
||||
t.Setenv("REMINDER_WEEKDAY", "")
|
||||
t.Setenv("REMINDER_HOUR", "")
|
||||
t.Setenv("DUTY_AHEAD_DAYS", "")
|
||||
|
||||
r, err := loadReminderConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadReminderConfig: %v", err)
|
||||
}
|
||||
if r.Weekday != time.Monday || r.Hour != 9 || r.AheadDays != 14 {
|
||||
t.Errorf("defaults: got %v %d ahead=%d, want Monday 9 ahead=14", r.Weekday, r.Hour, r.AheadDays)
|
||||
if r.Weekday != time.Monday || r.Hour != 9 {
|
||||
t.Errorf("defaults: got %v %d, want Monday 9", r.Weekday, r.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +149,6 @@ func TestLoadReminderConfigRejects(t *testing.T) {
|
|||
{"bad chat id", "GROUP_CHAT_ID", "not-a-number"},
|
||||
{"hour too high", "REMINDER_HOUR", "24"},
|
||||
{"hour negative", "REMINDER_HOUR", "-1"},
|
||||
{"ahead negative", "DUTY_AHEAD_DAYS", "-1"},
|
||||
{"bad weekday", "REMINDER_WEEKDAY", "Funday"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
|
|
@ -126,7 +156,6 @@ func TestLoadReminderConfigRejects(t *testing.T) {
|
|||
t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override
|
||||
t.Setenv("REMINDER_WEEKDAY", "Monday")
|
||||
t.Setenv("REMINDER_HOUR", "9")
|
||||
t.Setenv("DUTY_AHEAD_DAYS", "14")
|
||||
t.Setenv(tc.key, tc.val)
|
||||
|
||||
if _, err := loadReminderConfig(); err == nil {
|
||||
|
|
@ -152,6 +181,19 @@ func TestSyncSuccessReplacesCache(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAssignRange(t *testing.T) {
|
||||
now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
// Empty range -> current-year tab; the assignment targets cols B:C of the row.
|
||||
if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want {
|
||||
t.Errorf("assignRange(\"\"): got %q, want %q", got, want)
|
||||
}
|
||||
// Explicit range -> its tab is reused verbatim.
|
||||
if got, want := assignRange("Dyżury!A1:C30", now, 7), "Dyżury!B7:C7"; got != want {
|
||||
t.Errorf("assignRange(explicit): got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRange(t *testing.T) {
|
||||
now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
|
|
|
|||
64
sheet.go
64
sheet.go
|
|
@ -28,6 +28,10 @@ type DutyWeek struct {
|
|||
WeekStart time.Time // Monday that starts the week
|
||||
Person1 string // Osoba 1 — "" if unassigned
|
||||
Person2 string // Osoba 2 — "" if unassigned
|
||||
// Row is the 1-based sheet row this week occupies, used to address its
|
||||
// cells when writing an assignment back. Valid only when the read range
|
||||
// starts at A1 (as resolveRange always produces); 0 means unknown.
|
||||
Row int
|
||||
}
|
||||
|
||||
// Person is a single entry from the people list (columns E–G).
|
||||
|
|
@ -43,20 +47,37 @@ type Rota struct {
|
|||
People []Person
|
||||
}
|
||||
|
||||
// ReadSheet reads the given range from the sheet and returns its rows.
|
||||
// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30".
|
||||
// The API key is read from GOOGLE_API_KEY (loaded into the environment by
|
||||
// LoadConfig, which calls godotenv.Load at startup).
|
||||
func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) {
|
||||
apiKey := os.Getenv("GOOGLE_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env")
|
||||
// newSheetsService builds an authenticated Sheets client using the
|
||||
// service-account key file named by GOOGLE_CREDENTIALS_FILE (loaded into the
|
||||
// environment by LoadConfig, which calls godotenv.Load at startup). The
|
||||
// spreadsheets scope grants read+write; the sheet must be shared with the
|
||||
// service account's client_email for either to work.
|
||||
func newSheetsService(ctx context.Context) (*sheets.Service, error) {
|
||||
credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE")
|
||||
if credPath == "" {
|
||||
return nil, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env")
|
||||
}
|
||||
|
||||
srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey))
|
||||
// WithAuthCredentialsFile pins the credential type to ServiceAccount, which
|
||||
// is the non-deprecated replacement for WithCredentialsFile: it refuses an
|
||||
// unexpected credential type instead of loading it blindly.
|
||||
srv, err := sheets.NewService(ctx,
|
||||
option.WithAuthCredentialsFile(option.ServiceAccount, credPath),
|
||||
option.WithScopes(sheets.SpreadsheetsScope), // read+write
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init sheets service: %w", err)
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// ReadSheet reads the given range from the sheet and returns its rows.
|
||||
// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30".
|
||||
func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) {
|
||||
srv, err := newSheetsService(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := srv.Spreadsheets.Values.
|
||||
Get(spreadsheetID, readRange).
|
||||
|
|
@ -70,6 +91,30 @@ func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interf
|
|||
return resp.Values, nil
|
||||
}
|
||||
|
||||
// WriteSheet overwrites the cells starting at writeRange with values. writeRange
|
||||
// anchors the top-left cell (e.g. "Dyżury!G2"); values is row-major, so each
|
||||
// inner slice is one row written left-to-right. RAW input stores strings and
|
||||
// numbers verbatim — a leading "=" is not interpreted as a formula. The
|
||||
// service-account credential must have write access and the sheet be shared with
|
||||
// it (see newSheetsService).
|
||||
func WriteSheet(ctx context.Context, spreadsheetID, writeRange string, values [][]interface{}) error {
|
||||
srv, err := newSheetsService(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = srv.Spreadsheets.Values.
|
||||
Update(spreadsheetID, writeRange, &sheets.ValueRange{Values: values}).
|
||||
ValueInputOption("RAW").
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update values %q: %w", writeRange, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseRota interprets the raw rows from ReadSheet as two independent tables:
|
||||
// the duty schedule (columns A–C) and the people list (columns E–G).
|
||||
// The header row is skipped; malformed cells are ignored, not reported.
|
||||
|
|
@ -88,6 +133,7 @@ func ParseRota(rows [][]interface{}) Rota {
|
|||
WeekStart: serialToDate(serial),
|
||||
Person1: cellString(row, colPerson1),
|
||||
Person2: cellString(row, colPerson2),
|
||||
Row: i + 1, // slice index -> 1-based sheet row (range starts at A1)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ func TestParseRotaDecodesWeekStartToMonday(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseRotaTracksSheetRow(t *testing.T) {
|
||||
rota := ParseRota(sampleRows())
|
||||
|
||||
// The read range starts at A1, so a week at slice index i sits on sheet row
|
||||
// i+1. The four weeks come from indices 1..4 -> rows 2..5.
|
||||
wantRows := []int{2, 3, 4, 5}
|
||||
for i, want := range wantRows {
|
||||
if got := rota.Weeks[i].Row; got != want {
|
||||
t.Errorf("week %d: Row = %d, want %d", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRotaWeekPersons(t *testing.T) {
|
||||
rota := ParseRota(sampleRows())
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue