| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "strings" |
| 6 | "time" |
| 7 | |
| 8 | "gorm.io/gorm" |
| 9 | |
| 10 | "amadan.net/oficina/sheets/internal/sheets/store" |
| 11 | ) |
| 12 | |
| 13 | // maxTitleRunes caps a sheet title. It is a UI-shaped limit, not a |
| 14 | // storage one — the sealed column would take anything — and exists so a |
| 15 | // pasted paragraph cannot become a sheet name that breaks every row of |
| 16 | // the list. |
| 17 | const maxTitleRunes = 200 |
| 18 | |
| 19 | // firstTabName is what a new sheet's only tab is called. Sealed like |
| 20 | // any other tab name: a tab name is sheet content (spec §2.1), and the |
| 21 | // fact that this particular one is predictable does not make it exempt — |
| 22 | // an exemption would be a plaintext write on the same column every |
| 23 | // sealed name uses. |
| 24 | const firstTabName = "Sheet 1" |
| 25 | |
| 26 | // The grid paints a window, never a whole tab. Spec §6's reasoning for |
| 27 | // the 200,000-cell cap is that a tab rendered whole is a |
| 28 | // several-hundred-megabyte page; a window is the smallest thing that |
| 29 | // honours it, and it is a window rather than virtual scrolling because |
| 30 | // virtualisation is a lot of client code to write before anybody has |
| 31 | // typed into a single cell. |
| 32 | // |
| 33 | // defaultWindow* is what a page opens with. maxWindow* is what a |
| 34 | // request may ask for: past it the numbers are clamped, not refused, |
| 35 | // because refusing turns a too-wide window into a broken page while |
| 36 | // clamping turns it into a slightly smaller one. |
| 37 | const ( |
| 38 | defaultWindowRows = 100 |
| 39 | defaultWindowCols = 26 // A..Z, which is what a person expects to see |
| 40 | maxWindowRows = 500 |
| 41 | maxWindowCols = 60 |
| 42 | ) |
| 43 | |
| 44 | // errNoSealer is returned rather than panicking when an App built |
| 45 | // without a sealer (page-rendering tests) reaches a path that needs one. |
| 46 | var errNoSealer = errors.New("sheets: no sealer configured") |
| 47 | |
| 48 | // SheetSummary is one row of the sheet list: what a list row or a page |
| 49 | // header needs, with the title already unsealed. |
| 50 | // |
| 51 | // TabID is the sheet's FIRST tab, which is the one a bare /sheets/{id} |
| 52 | // opens. Carrying it here means the grid page never has to query for it, |
| 53 | // and multi-tab navigation later adds a field rather than a round trip. |
| 54 | type SheetSummary struct { |
| 55 | ID uint64 |
| 56 | Title string |
| 57 | TabID uint64 |
| 58 | // TabName is the first tab's name, for the tab strip along the |
| 59 | // bottom of the grid. Sealed at rest like every other piece of |
| 60 | // authored text, and unsealed here beside the title so the page has |
| 61 | // one place that opens a sheet's names rather than two. |
| 62 | // |
| 63 | // A tab whose name will not unseal falls back to "Sheet 1" rather |
| 64 | // than failing the page: the strip is chrome, and a grid nobody can |
| 65 | // open because its tab has an unreadable name is a worse outcome |
| 66 | // than a tab labelled generically. |
| 67 | TabName string |
| 68 | UpdatedAt time.Time |
| 69 | } |
| 70 | |
| 71 | // createSheet makes a sheet and its first tab in one transaction. |
| 72 | // |
| 73 | // One transaction because a sheet with no tab is not openable: it would |
| 74 | // appear in the list and then 404 when clicked. Nothing outside this |
| 75 | // function ever creates a Sheet row, which is what makes "every sheet |
| 76 | // has at least one tab" a real invariant rather than a convention |
| 77 | // everyone remembers. |
| 78 | // createSheet makes an ordinary, immediately visible sheet. |
| 79 | func (a *App) createSheet(title string, by Member) (SheetSummary, error) { |
| 80 | return a.newSheet(title, by, false) |
| 81 | } |
| 82 | |
| 83 | // createFillingSheet makes a sheet that is HIDDEN until something |
| 84 | // finishes filling it — import's shape (store.Sheet.FillingSince). |
| 85 | // |
| 86 | // The caller owns the other half: markFilled on success, discardSheet |
| 87 | // on failure. A caller that does neither leaves an invisible row, which |
| 88 | // is the failure this is designed to have. |
| 89 | func (a *App) createFillingSheet(title string, by Member) (SheetSummary, error) { |
| 90 | return a.newSheet(title, by, true) |
| 91 | } |
| 92 | |
| 93 | func (a *App) newSheet(title string, by Member, filling bool) (SheetSummary, error) { |
| 94 | if a.db == nil { |
| 95 | return SheetSummary{}, errNoDB |
| 96 | } |
| 97 | if a.sealer == nil { |
| 98 | return SheetSummary{}, errNoSealer |
| 99 | } |
| 100 | clean := strings.TrimSpace(title) |
| 101 | if clean == "" { |
| 102 | return SheetSummary{}, errors.New("sheets: a sheet needs a title") |
| 103 | } |
| 104 | if r := []rune(clean); len(r) > maxTitleRunes { |
| 105 | clean = string(r[:maxTitleRunes]) |
| 106 | } |
| 107 | sealedTitle, err := a.sealer.Seal("sheet.title", []byte(clean)) |
| 108 | if err != nil { |
| 109 | return SheetSummary{}, err |
| 110 | } |
| 111 | sealedName, err := a.sealer.Seal("tab.name", []byte(firstTabName)) |
| 112 | if err != nil { |
| 113 | return SheetSummary{}, err |
| 114 | } |
| 115 | |
| 116 | sheet := store.Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID} |
| 117 | if filling { |
| 118 | now := time.Now().UTC() |
| 119 | sheet.FillingSince = &now |
| 120 | } |
| 121 | tab := store.Tab{NameSealed: sealedName, Position: 0} |
| 122 | if err := a.db.Transaction(func(tx *gorm.DB) error { |
| 123 | if err := tx.Create(&sheet).Error; err != nil { |
| 124 | return err |
| 125 | } |
| 126 | tab.SheetID = sheet.ID |
| 127 | if err := tx.Create(&tab).Error; err != nil { |
| 128 | return err |
| 129 | } |
| 130 | // The creator owns it, written here rather than inferred from |
| 131 | // Sheet.CreatedBy. Inferring would mean two sources of truth |
| 132 | // for one question, and the share screen could not then take |
| 133 | // ownership away or hand it on. |
| 134 | return tx.Create(&Grant{ |
| 135 | SheetID: sheet.ID, MemberID: by.ID, |
| 136 | Role: string(SheetOwner), GrantedBy: by.ID, |
| 137 | }).Error |
| 138 | }); err != nil { |
| 139 | return SheetSummary{}, err |
| 140 | } |
| 141 | return SheetSummary{ |
| 142 | ID: sheet.ID, Title: clean, TabID: tab.ID, |
| 143 | TabName: firstTabName, UpdatedAt: sheet.UpdatedAt, |
| 144 | }, nil |
| 145 | } |
| 146 | |
| 147 | // listSheets returns the sheets a member may see, most recently |
| 148 | // touched first. |
| 149 | // |
| 150 | // It grew the hand-written grants join the previous comment here |
| 151 | // promised — visibleSheets, grants.go — and it did NOT grow a |
| 152 | // scope.Owned. scope.Owned is single-column equality on user_id, our |
| 153 | // sheets are team-shared, and applied here it would return only the |
| 154 | // sheets somebody created: a plausible short list rather than an error |
| 155 | // (AGENTS.md / Mechanical rules). |
| 156 | // |
| 157 | // A team owner or member sees every sheet, which is what one instance |
| 158 | // per team means. A guest sees exactly what they were granted. |
| 159 | func (a *App) listSheets(viewer Member) ([]SheetSummary, error) { |
| 160 | if a.db == nil { |
| 161 | return nil, errNoDB |
| 162 | } |
| 163 | if a.sealer == nil { |
| 164 | return nil, errNoSealer |
| 165 | } |
| 166 | rows, err := a.visibleSheets(viewer) |
| 167 | if err != nil { |
| 168 | return nil, err |
| 169 | } |
| 170 | out := make([]SheetSummary, 0, len(rows)) |
| 171 | for _, s := range rows { |
| 172 | sum, err := a.summarize(s) |
| 173 | if err != nil { |
| 174 | return nil, err |
| 175 | } |
| 176 | out = append(out, sum) |
| 177 | } |
| 178 | return out, nil |
| 179 | } |
| 180 | |
| 181 | // sheetSummary reads one sheet a viewer may see. |
| 182 | // |
| 183 | // A sheet that is not there and a sheet they may not reach are the same |
| 184 | // answer, on purpose: handlers turn both into a 404, never a 403 |
| 185 | // (AGENTS.md). A 403 confirms the row exists, and on an instance where |
| 186 | // guests hold single-sheet grants the set of ids that 403 is a map of |
| 187 | // the team's sheets. |
| 188 | func (a *App) sheetSummary(id uint64, viewer Member) (SheetSummary, error) { |
| 189 | if a.db == nil { |
| 190 | return SheetSummary{}, errNoDB |
| 191 | } |
| 192 | if a.sealer == nil { |
| 193 | return SheetSummary{}, errNoSealer |
| 194 | } |
| 195 | if !a.mayRead(id, viewer) { |
| 196 | return SheetSummary{}, errNoAccess |
| 197 | } |
| 198 | var s store.Sheet |
| 199 | if err := a.db.Where("id = ? AND deleted_at IS NULL AND filling_since IS NULL", id).First(&s).Error; err != nil { |
| 200 | return SheetSummary{}, err |
| 201 | } |
| 202 | return a.summarize(s) |
| 203 | } |
| 204 | |
| 205 | // tabBelongsTo reports whether a tab is part of a sheet. |
| 206 | // |
| 207 | // Every grid and stream route takes both ids from the URL, so without |
| 208 | // this a caller could pair any sheet id with any tab id and read a tab |
| 209 | // through a sheet it does not belong to. Once grants exist, the sheet is |
| 210 | // where permission will be checked, which makes this pairing check the |
| 211 | // thing that keeps the permission meaningful. |
| 212 | func (a *App) tabBelongsTo(sheetID, tabID uint64) bool { |
| 213 | if a.db == nil { |
| 214 | return false |
| 215 | } |
| 216 | var n int64 |
| 217 | if err := a.db.Model(&store.Tab{}). |
| 218 | Where("id = ? AND sheet_id = ?", tabID, sheetID).Count(&n).Error; err != nil { |
| 219 | return false |
| 220 | } |
| 221 | return n == 1 |
| 222 | } |
| 223 | |
| 224 | // touchSheet bumps a sheet's UpdatedAt so the list orders by real |
| 225 | // activity rather than by creation. Called on every cell write; a |
| 226 | // failure is not worth failing the write over, so callers ignore the |
| 227 | // error and the list is merely stale. |
| 228 | func (a *App) touchSheet(sheetID uint64) { |
| 229 | if a.db == nil { |
| 230 | return |
| 231 | } |
| 232 | a.db.Model(&store.Sheet{}).Where("id = ?", sheetID). |
| 233 | Update("updated_at", time.Now().UTC()) |
| 234 | } |
| 235 | |
| 236 | // summarize unseals a Sheet row's title and finds its first tab. |
| 237 | func (a *App) summarize(s store.Sheet) (SheetSummary, error) { |
| 238 | title, err := a.sealer.Open("sheet.title", s.TitleSealed) |
| 239 | if err != nil { |
| 240 | return SheetSummary{}, err |
| 241 | } |
| 242 | var tab store.Tab |
| 243 | if err := a.db.Where("sheet_id = ?", s.ID). |
| 244 | Order("position ASC, id ASC").First(&tab).Error; err != nil { |
| 245 | return SheetSummary{}, err |
| 246 | } |
| 247 | return SheetSummary{ |
| 248 | ID: s.ID, Title: string(title), TabID: tab.ID, |
| 249 | TabName: tabName(a, tab), UpdatedAt: s.UpdatedAt, |
| 250 | }, nil |
| 251 | } |
| 252 | |
| 253 | // tabName unseals a tab's name, falling back to a generic label. |
| 254 | // |
| 255 | // The fallback is for a name that will not open — a row written under a |
| 256 | // key this process does not have. It is not for an empty NameSealed: |
| 257 | // newSheet has always sealed firstTabName, so no tab this app has |
| 258 | // made lacks one. Either way the answer is the same and it is the |
| 259 | // right one: the tab strip is chrome, and a grid nobody can open |
| 260 | // because its tab's name is unreadable is a worse outcome than a tab |
| 261 | // labelled generically. |
| 262 | func tabName(a *App, tab store.Tab) string { |
| 263 | if len(tab.NameSealed) == 0 { |
| 264 | return "Sheet 1" |
| 265 | } |
| 266 | name, err := a.sealer.Open("tab.name", tab.NameSealed) |
| 267 | if err != nil || len(name) == 0 { |
| 268 | return "Sheet 1" |
| 269 | } |
| 270 | return string(name) |
| 271 | } |
| 272 | |