| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | |
| 11 | "amadan.net/oficina/calendar/internal/calendar/seal" |
| 12 | ) |
| 13 | |
| 14 | // ErrNotFound is what every scoped read returns when the row does not |
| 15 | // exist OR the viewer has no grant on it. One error for both, on |
| 16 | // purpose: a handler that turned "exists but not yours" into 403 would |
| 17 | // let anyone enumerate the team's calendars by watching which ids |
| 18 | // answered differently. A row that isn't yours doesn't exist. |
| 19 | var ErrNotFound = errors.New("calendar: not found") |
| 20 | |
| 21 | // ErrReadOnly is a viewer attempting a write. It is separate from |
| 22 | // ErrNotFound because the viewer CAN see the thing — they are looking |
| 23 | // at its page — so hiding it would be a lie rather than a defence. |
| 24 | var ErrReadOnly = errors.New("calendar: read only") |
| 25 | |
| 26 | // CalendarView is a Calendar with its sealed columns opened. Handlers |
| 27 | // and templates only ever see this; the sealed struct never leaves the |
| 28 | // store. |
| 29 | type CalendarView struct { |
| 30 | Calendar |
| 31 | Name string |
| 32 | |
| 33 | // Role is the viewer's grant on it, so a template can hide an edit |
| 34 | // control the server would refuse anyway. |
| 35 | Role string |
| 36 | } |
| 37 | |
| 38 | // TeamCalendarName is what the one shared calendar is called before |
| 39 | // anybody renames it. |
| 40 | const TeamCalendarName = "Team" |
| 41 | |
| 42 | // EnsurePersonSetup makes a newly-signed-in person usable: their row, |
| 43 | // the instance's one team calendar, a grant onto it, and a personal |
| 44 | // calendar of their own. |
| 45 | // |
| 46 | // Done here rather than at boot because a boot-time write is a write |
| 47 | // every instance performs whether or not anybody ever signs in, and |
| 48 | // because the team calendar's first member is the fact that creates it. |
| 49 | func (s *Store) EnsurePersonSetup(ctx context.Context, address string) (Person, error) { |
| 50 | p, err := s.EnsurePerson(ctx, address) |
| 51 | if err != nil { |
| 52 | return Person{}, err |
| 53 | } |
| 54 | team, err := s.ensureTeamCalendar(ctx, p.ID) |
| 55 | if err != nil { |
| 56 | return Person{}, err |
| 57 | } |
| 58 | // Editor, not owner: the team calendar belongs to the team, and |
| 59 | // nobody should be able to delete it out from under the others. |
| 60 | if err := s.ensureGrant(ctx, team.ID, p.ID, RoleEditor, p.ID); err != nil { |
| 61 | return Person{}, err |
| 62 | } |
| 63 | if err := s.ensurePersonalCalendar(ctx, p); err != nil { |
| 64 | return Person{}, err |
| 65 | } |
| 66 | return p, nil |
| 67 | } |
| 68 | |
| 69 | func (s *Store) ensureTeamCalendar(ctx context.Context, creator int64) (Calendar, error) { |
| 70 | var c Calendar |
| 71 | err := s.g.WithContext(ctx).Where("kind = ?", "team").First(&c).Error |
| 72 | if err == nil { |
| 73 | return c, nil |
| 74 | } |
| 75 | if !errors.Is(err, gorm.ErrRecordNotFound) { |
| 76 | return Calendar{}, fmt.Errorf("calendar: find team calendar: %w", err) |
| 77 | } |
| 78 | name, err := s.seal.SealString(seal.PurposeCalendarName, TeamCalendarName) |
| 79 | if err != nil { |
| 80 | return Calendar{}, fmt.Errorf("calendar: seal team calendar name: %w", err) |
| 81 | } |
| 82 | c = Calendar{ |
| 83 | NameSealed: name, |
| 84 | Kind: "team", |
| 85 | OwnerID: creator, |
| 86 | Hue: teamHue, |
| 87 | CreatedAt: time.Now().UTC(), |
| 88 | } |
| 89 | if err := s.g.WithContext(ctx).Create(&c).Error; err != nil { |
| 90 | // A concurrent first sign-in may have won the race. Re-read |
| 91 | // rather than fail: either row is the team calendar. |
| 92 | var again Calendar |
| 93 | if e := s.g.WithContext(ctx).Where("kind = ?", "team").First(&again).Error; e == nil { |
| 94 | return again, nil |
| 95 | } |
| 96 | return Calendar{}, fmt.Errorf("calendar: create team calendar: %w", err) |
| 97 | } |
| 98 | return c, nil |
| 99 | } |
| 100 | |
| 101 | func (s *Store) ensurePersonalCalendar(ctx context.Context, p Person) error { |
| 102 | var n int64 |
| 103 | if err := s.g.WithContext(ctx).Model(&Calendar{}). |
| 104 | Where("kind = ? AND owner_id = ?", "personal", p.ID).Count(&n).Error; err != nil { |
| 105 | return fmt.Errorf("calendar: count personal calendars: %w", err) |
| 106 | } |
| 107 | if n > 0 { |
| 108 | return nil |
| 109 | } |
| 110 | name, err := s.seal.SealString(seal.PurposeCalendarName, "My calendar") |
| 111 | if err != nil { |
| 112 | return fmt.Errorf("calendar: seal personal calendar name: %w", err) |
| 113 | } |
| 114 | c := Calendar{ |
| 115 | NameSealed: name, |
| 116 | Kind: "personal", |
| 117 | OwnerID: p.ID, |
| 118 | Hue: personalHue(p.ID), |
| 119 | CreatedAt: time.Now().UTC(), |
| 120 | } |
| 121 | if err := s.g.WithContext(ctx).Create(&c).Error; err != nil { |
| 122 | return fmt.Errorf("calendar: create personal calendar: %w", err) |
| 123 | } |
| 124 | return s.ensureGrant(ctx, c.ID, p.ID, RoleOwner, p.ID) |
| 125 | } |
| 126 | |
| 127 | func (s *Store) ensureGrant(ctx context.Context, calendarID, personID int64, role string, by int64) error { |
| 128 | g := Grant{} |
| 129 | err := s.g.WithContext(ctx). |
| 130 | Where(Grant{CalendarID: calendarID, PersonID: personID}). |
| 131 | Attrs(Grant{Role: role, GrantedBy: by, CreatedAt: time.Now().UTC()}). |
| 132 | FirstOrCreate(&g).Error |
| 133 | if err != nil { |
| 134 | return fmt.Errorf("calendar: ensure grant: %w", err) |
| 135 | } |
| 136 | return nil |
| 137 | } |
| 138 | |
| 139 | // Calendars lists what a person may see, most recently created last so |
| 140 | // the team calendar — always the first row written — leads. |
| 141 | func (s *Store) Calendars(ctx context.Context, personID int64) ([]CalendarView, error) { |
| 142 | type row struct { |
| 143 | Calendar |
| 144 | Role string |
| 145 | } |
| 146 | var rows []row |
| 147 | err := s.g.WithContext(ctx). |
| 148 | Model(&Calendar{}). |
| 149 | Select("calendars.*, grants.role as role"). |
| 150 | Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID). |
| 151 | Where("calendars.archived_at IS NULL"). |
| 152 | Order("calendars.id"). |
| 153 | Scan(&rows).Error |
| 154 | if err != nil { |
| 155 | return nil, fmt.Errorf("calendar: list calendars: %w", err) |
| 156 | } |
| 157 | out := make([]CalendarView, 0, len(rows)) |
| 158 | for _, r := range rows { |
| 159 | v, err := s.openCalendar(r.Calendar, r.Role) |
| 160 | if err != nil { |
| 161 | return nil, err |
| 162 | } |
| 163 | out = append(out, v) |
| 164 | } |
| 165 | return out, nil |
| 166 | } |
| 167 | |
| 168 | // Calendar reads one, scoped. ErrNotFound covers both "no such id" and |
| 169 | // "no grant" — see its own doc comment. |
| 170 | func (s *Store) Calendar(ctx context.Context, personID, calendarID int64) (CalendarView, error) { |
| 171 | type row struct { |
| 172 | Calendar |
| 173 | Role string |
| 174 | } |
| 175 | var r row |
| 176 | err := s.g.WithContext(ctx). |
| 177 | Model(&Calendar{}). |
| 178 | Select("calendars.*, grants.role as role"). |
| 179 | Joins("JOIN grants ON grants.calendar_id = calendars.id AND grants.person_id = ?", personID). |
| 180 | Where("calendars.id = ?", calendarID). |
| 181 | Limit(1). |
| 182 | Scan(&r).Error |
| 183 | if err != nil { |
| 184 | return CalendarView{}, fmt.Errorf("calendar: read calendar: %w", err) |
| 185 | } |
| 186 | if r.Calendar.ID == 0 { |
| 187 | return CalendarView{}, ErrNotFound |
| 188 | } |
| 189 | return s.openCalendar(r.Calendar, r.Role) |
| 190 | } |
| 191 | |
| 192 | func (s *Store) openCalendar(c Calendar, role string) (CalendarView, error) { |
| 193 | name, err := s.seal.OpenString(seal.PurposeCalendarName, c.NameSealed) |
| 194 | if err != nil { |
| 195 | // No name in the error: it is the thing we failed to decrypt, |
| 196 | // and an error string is somewhere we cannot control the |
| 197 | // storage of. |
| 198 | return CalendarView{}, fmt.Errorf("calendar: open calendar name (id %d): %w", c.ID, err) |
| 199 | } |
| 200 | return CalendarView{Calendar: c, Name: name, Role: role}, nil |
| 201 | } |
| 202 | |
| 203 | // DefaultCalendarID is where a new event lands when nobody has said |
| 204 | // otherwise: the person's own calendar, falling back to the first they |
| 205 | // can write. |
| 206 | // |
| 207 | // Personal rather than team, deliberately. Putting a half-formed event |
| 208 | // on the shared calendar by default means the cost of forgetting to |
| 209 | // change a dropdown is everybody else seeing it, and that asymmetry |
| 210 | // should fall on the side of privacy. |
| 211 | func (s *Store) DefaultCalendarID(ctx context.Context, personID int64) (int64, error) { |
| 212 | cals, err := s.Calendars(ctx, personID) |
| 213 | if err != nil { |
| 214 | return 0, err |
| 215 | } |
| 216 | for _, c := range cals { |
| 217 | if c.Kind == "personal" && c.OwnerID == personID { |
| 218 | return c.ID, nil |
| 219 | } |
| 220 | } |
| 221 | for _, c := range cals { |
| 222 | if CanWrite(c.Role) { |
| 223 | return c.ID, nil |
| 224 | } |
| 225 | } |
| 226 | return 0, nil |
| 227 | } |
| 228 | |
| 229 | // Colour is an intent on the hue wheel, resolved to a real pair by the |
| 230 | // theme so it clears the contrast floor in both schemes. teamHue is a |
| 231 | // settled blue; a personal calendar is spread around the wheel by id so |
| 232 | // two people rarely collide, and never predictably. |
| 233 | const teamHue = 220 |
| 234 | |
| 235 | func personalHue(personID int64) int { |
| 236 | // 137 is close to the golden angle in degrees, which spreads |
| 237 | // successive ids about as far apart as a fixed step can. |
| 238 | return int((personID*137 + 40) % 360) |
| 239 | } |
| 240 | |