oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
20const 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.
26type 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
46func 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.
57func (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.
88func (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