| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log/slog" |
| 8 | "sync" |
| 9 | "time" |
| 10 | |
| 11 | "gorm.io/gorm" |
| 12 | |
| 13 | "amadan.net/oficina/calendar/internal/calendar/seal" |
| 14 | ) |
| 15 | |
| 16 | // defaultZone is the zone a person gets before they have chosen one. |
| 17 | // UTC, not the server's local zone: a server's zone is an accident of |
| 18 | // where it was provisioned, and inheriting it would silently shift |
| 19 | // everyone's day boundaries the first time the fleet moved. |
| 20 | const defaultZone = "UTC" |
| 21 | |
| 22 | // Store is every database access this app makes. Handlers never touch |
| 23 | // gorm directly — one seam, so owner scoping is applied in one place |
| 24 | // and can be tested in one place, and so every sealed column is sealed |
| 25 | // and opened on the way past rather than at each call site. |
| 26 | type Store struct { |
| 27 | g *gorm.DB |
| 28 | seal *seal.Sealer |
| 29 | logger *slog.Logger |
| 30 | |
| 31 | // holdMu serialises hold creation and rescheduling (booking spec |
| 32 | // B12). SQLite begins deferred, so two transactions can both read |
| 33 | // "free" before either takes the write lock; one process per |
| 34 | // instance makes a mutex the honest serialisation. It orders |
| 35 | // booking requests against each other — the write lock taken |
| 36 | // inside the transaction orders them against everything else. |
| 37 | holdMu sync.Mutex |
| 38 | |
| 39 | // afterSlotCheckForTest, when set, runs between a hold's |
| 40 | // response-time slot check and its transaction. A test uses it to |
| 41 | // commit an ordinary event over the slot and prove the in- |
| 42 | // transaction re-check catches it. Nil in production. |
| 43 | afterSlotCheckForTest func() |
| 44 | } |
| 45 | |
| 46 | func NewStore(g *gorm.DB, sealer *seal.Sealer, logger *slog.Logger) *Store { |
| 47 | return &Store{g: g, seal: sealer, logger: logger} |
| 48 | } |
| 49 | |
| 50 | // EnsurePerson returns the Person row for a verified address, creating |
| 51 | // it on first sign-in. |
| 52 | // |
| 53 | // The address is normalised first, so the unique index sees one |
| 54 | // spelling per person. The insert is an upsert on that index rather |
| 55 | // than a read-then-write: two sign-in links clicked at the same moment |
| 56 | // would otherwise both read "no row" and both insert. |
| 57 | func (s *Store) EnsurePerson(ctx context.Context, address string) (Person, error) { |
| 58 | addr := normalizeAddress(address) |
| 59 | if addr == "" { |
| 60 | return Person{}, errors.New("calendar: EnsurePerson: empty address") |
| 61 | } |
| 62 | now := time.Now().UTC() |
| 63 | var p Person |
| 64 | |
| 65 | // FirstOrCreate on the unique index, then read back: the row we |
| 66 | // want may be the one this statement wrote or the one a concurrent |
| 67 | // sign-in wrote, and either is correct. Two links clicked at the |
| 68 | // same moment would otherwise both read "no row" and both insert. |
| 69 | if err := s.g.WithContext(ctx). |
| 70 | Where(Person{Address: addr}). |
| 71 | Attrs(Person{ |
| 72 | Zone: defaultZone, |
| 73 | WeekStart: WeekStartMonday, |
| 74 | DefaultReminderMin: DefaultReminderMin, |
| 75 | DefaultAllDayMin: DefaultAllDayMin, |
| 76 | CreatedAt: now, |
| 77 | UpdatedAt: now, |
| 78 | }). |
| 79 | FirstOrCreate(&p).Error; err != nil { |
| 80 | return Person{}, fmt.Errorf("calendar: ensure person: %w", err) |
| 81 | } |
| 82 | return p, nil |
| 83 | } |
| 84 | |
| 85 | // PersonByAddress reads the row for an already-known address. A missing |
| 86 | // row is (Person{}, false, nil) rather than an error: "this address has |
| 87 | // never signed in" is an ordinary answer, not a failure. |
| 88 | func (s *Store) PersonByAddress(ctx context.Context, address string) (Person, bool, error) { |
| 89 | addr := normalizeAddress(address) |
| 90 | if addr == "" { |
| 91 | return Person{}, false, nil |
| 92 | } |
| 93 | var p Person |
| 94 | err := s.g.WithContext(ctx).Where("address = ?", addr).First(&p).Error |
| 95 | switch { |
| 96 | case errors.Is(err, gorm.ErrRecordNotFound): |
| 97 | return Person{}, false, nil |
| 98 | case err != nil: |
| 99 | return Person{}, false, fmt.Errorf("calendar: person by address: %w", err) |
| 100 | } |
| 101 | return p, true, nil |
| 102 | } |
| 103 | |