| 1 | package calendar |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // Models is every model the schema generator manages. Keep it in step |
| 6 | // with the structs below and in models_event.go: `rastrillo migration |
| 7 | // generate` reads it to work out what the database should look like, |
| 8 | // and `rastrillo migration check` fails CI when it and the migrations |
| 9 | // disagree. |
| 10 | var Models = []any{ |
| 11 | &Person{}, |
| 12 | &Calendar{}, |
| 13 | &Grant{}, |
| 14 | &Event{}, |
| 15 | &Attendee{}, |
| 16 | &Reminder{}, |
| 17 | &ReminderQueue{}, |
| 18 | &Feed{}, |
| 19 | &Meta{}, |
| 20 | &BookingPage{}, |
| 21 | &BookingWindow{}, |
| 22 | &Booking{}, |
| 23 | &BookingMail{}, |
| 24 | } |
| 25 | |
| 26 | // Person is an address that has signed in here, and the row everything |
| 27 | // owned is scoped to. |
| 28 | // |
| 29 | // The indirection is not ceremony. Sign-in is a magic link, so the |
| 30 | // session Subject is a verified email address, not a numeric id — which |
| 31 | // is why sessions.UserID is unusable under this plugin and returns |
| 32 | // (0, false). Owning rows by an address column instead would scatter a |
| 33 | // piece of personal data across every table and make a change of |
| 34 | // address a migration; owning them by this row's id keeps the address |
| 35 | // in one place. |
| 36 | // |
| 37 | // Named Person rather than the brainstorm's Member, matching docs. |
| 38 | // Sheets calls the same row Member. Nothing turns on it, and the table |
| 39 | // shipped before the difference was noticed, so it stays: a rename is |
| 40 | // indistinguishable from a drop plus an add, and this one buys nothing. |
| 41 | type Person struct { |
| 42 | ID int64 `gorm:"primaryKey"` |
| 43 | |
| 44 | // Address is the verified sign-in address, normalised (trimmed, |
| 45 | // lowercased) by normalizeAddress before it is ever written or |
| 46 | // compared, so two spellings of one person cannot become two rows. |
| 47 | // |
| 48 | // Plaintext, deliberately. The team already knows it, a reminder |
| 49 | // email needs it to be deliverable, and the leak list says so. |
| 50 | Address string `gorm:"column:address;uniqueIndex;not null"` |
| 51 | |
| 52 | // NameSealed is a display name, if they set one. Sealed: it is |
| 53 | // content, and sheets seals the equivalent. |
| 54 | NameSealed []byte `gorm:"column:name_sealed"` |
| 55 | |
| 56 | // Zone is the IANA zone this person reads their calendar in |
| 57 | // ("Europe/Dublin"). Stated, never sniffed — see the settings page. |
| 58 | // A browser's zone is a guess about a device, not a statement about |
| 59 | // a person, and a calendar that silently re-times everything |
| 60 | // because somebody opened it on holiday is a calendar that lies. |
| 61 | Zone string `gorm:"column:zone;not null"` |
| 62 | |
| 63 | // Handle is the plaintext name in this person's public booking |
| 64 | // URLs, /book/{handle}/{code}: [a-z0-9-]{2,32}, lowercased on save, |
| 65 | // NULL until they choose one. Plaintext because it is in a URL the |
| 66 | // person hands to strangers, and unique because two people cannot |
| 67 | // share a URL prefix. Nullable so the unique index admits any |
| 68 | // number of people who never set one. |
| 69 | Handle *string `gorm:"column:handle;uniqueIndex"` |
| 70 | |
| 71 | // WeekStart is 1 for Monday (the default), 7 for Sunday, 6 for |
| 72 | // Saturday — time.Weekday's numbering with Sunday as 7 rather than |
| 73 | // 0, so "unset" is distinguishable from "Sunday". |
| 74 | WeekStart int `gorm:"column:week_start;not null"` |
| 75 | |
| 76 | // The defaults applied to events this person creates. Separate for |
| 77 | // timed and all-day because "10 minutes before" is useless for a |
| 78 | // birthday and "the evening before" is useless for a standup. |
| 79 | DefaultReminderMin int `gorm:"column:default_reminder_min;not null"` |
| 80 | DefaultAllDayMin int `gorm:"column:default_all_day_min;not null"` |
| 81 | |
| 82 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 83 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 84 | } |
| 85 | |
| 86 | // Calendar is a container of events: one team calendar created with the |
| 87 | // instance, plus a personal calendar per person. |
| 88 | type Calendar struct { |
| 89 | ID int64 `gorm:"primaryKey"` |
| 90 | |
| 91 | // NameSealed is content, like a sheet's title. |
| 92 | NameSealed []byte `gorm:"column:name_sealed"` |
| 93 | |
| 94 | // Kind is "team" or "personal". The team calendar is the one every |
| 95 | // person is granted editor on at creation; a personal one is |
| 96 | // private until shared. |
| 97 | Kind string `gorm:"column:kind;not null"` |
| 98 | |
| 99 | OwnerID int64 `gorm:"column:owner_id;not null;index"` |
| 100 | |
| 101 | // Hue is where this calendar's colour sits on the wheel, 0-360. An |
| 102 | // intent rather than a hex value: the theme resolves it to a pair |
| 103 | // that clears the contrast floor in both light and dark, which a |
| 104 | // stored hex cannot do. |
| 105 | // |
| 106 | // Colour never carries meaning alone here — every chip prints its |
| 107 | // own title and time, and the calendar's name is on the page. |
| 108 | Hue int `gorm:"column:hue;not null"` |
| 109 | |
| 110 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 111 | ArchivedAt *time.Time `gorm:"column:archived_at;index"` |
| 112 | } |
| 113 | |
| 114 | // Grant is the calendar-to-person join, and the one place authorisation |
| 115 | // is decided. |
| 116 | // |
| 117 | // Explicit rows even for the team calendar, where every person gets one |
| 118 | // at creation. One query shape then serves every calendar, and |
| 119 | // "a row that isn't yours 404s" has exactly one home rather than a |
| 120 | // special case that someone will later forget. |
| 121 | type Grant struct { |
| 122 | ID int64 `gorm:"primaryKey"` |
| 123 | CalendarID int64 `gorm:"column:calendar_id;not null;uniqueIndex:idx_grant_cal_person"` |
| 124 | PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_grant_cal_person"` |
| 125 | |
| 126 | // Role is "owner", "editor" or "viewer". |
| 127 | Role string `gorm:"column:role;not null"` |
| 128 | |
| 129 | GrantedBy int64 `gorm:"column:granted_by;not null"` |
| 130 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 131 | } |
| 132 | |
| 133 | // The roles, as constants, because a role compared against a literal is |
| 134 | // a role that will eventually be compared against a typo. |
| 135 | const ( |
| 136 | RoleOwner = "owner" |
| 137 | RoleEditor = "editor" |
| 138 | RoleViewer = "viewer" |
| 139 | ) |
| 140 | |
| 141 | // CanWrite reports whether a role may change what it names. |
| 142 | func CanWrite(role string) bool { |
| 143 | return role == RoleOwner || role == RoleEditor |
| 144 | } |
| 145 | |
| 146 | // Week start, using time.Weekday's numbering except that Sunday is 7 |
| 147 | // rather than 0 — so a zero column means "never set" and is |
| 148 | // distinguishable from a deliberate Sunday. |
| 149 | const ( |
| 150 | WeekStartMonday = 1 |
| 151 | WeekStartSaturday = 6 |
| 152 | WeekStartSunday = 7 |
| 153 | ) |
| 154 | |
| 155 | // The reminder defaults a new person gets. |
| 156 | // |
| 157 | // Ten minutes before a timed event is enough to walk to a room and not |
| 158 | // so much that it arrives while you are still in the previous thing. |
| 159 | // An all-day event is reminded the evening before at 18:00 — fifteen |
| 160 | // hours ahead of its midnight — because a birthday you learn about at |
| 161 | // midnight is a birthday you have already half-missed. |
| 162 | const ( |
| 163 | DefaultReminderMin = 10 |
| 164 | DefaultAllDayMin = 6 * 60 |
| 165 | ) |
| 166 | |
| 167 | // Meta is the instance's own key/value scratch: the tzdata stamp the |
| 168 | // re-derive sweep compares against, and anything else that is a fact |
| 169 | // about this database rather than about anybody's calendar. |
| 170 | type Meta struct { |
| 171 | Key string `gorm:"primaryKey"` |
| 172 | Value string `gorm:"column:value;not null"` |
| 173 | } |
| 174 | |
| 175 | // The Meta keys, spelled once. |
| 176 | const ( |
| 177 | MetaTzdata = "tzdata_version" |
| 178 | ) |
| 179 | |