| 1 | // decks.go is the deck: its identifiers, the one seam every deck read |
| 2 | // goes through, the two functions that move a document forward, and the |
| 3 | // six screens that stand on them. |
| 4 | // |
| 5 | // The split it keeps: the `decks` row is what a list, a link or a |
| 6 | // permission check needs; the Y.Doc is the content. Nothing here reads |
| 7 | // content out of a column, and nothing writes content anywhere but |
| 8 | // through an op batch the core applies. |
| 9 | package slides |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "crypto/rand" |
| 14 | "encoding/json" |
| 15 | "errors" |
| 16 | "fmt" |
| 17 | "net/http" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/go-chi/chi/v5" |
| 21 | "gorm.io/gorm" |
| 22 | |
| 23 | "amadan.net/rastrillo/rastrillo" |
| 24 | "amadan.net/rastrillo/rastrillo/form" |
| 25 | |
| 26 | "amadan.net/oficina/slides/internal/slides/core" |
| 27 | "amadan.net/oficina/slides/internal/slides/deck" |
| 28 | "amadan.net/oficina/slides/internal/slides/present" |
| 29 | ) |
| 30 | |
| 31 | // --- identifiers --- |
| 32 | |
| 33 | // sqidAlphabet avoids the characters a person misreads or mistypes |
| 34 | // when a deck link is read aloud: no 0/O, no 1/l/I. Docs' alphabet, |
| 35 | // character for character — the two apps' URLs should look like they |
| 36 | // come from the same suite. |
| 37 | const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" |
| 38 | |
| 39 | // sqidLength is how many of those characters a deck's public name has. |
| 40 | const sqidLength = 10 |
| 41 | |
| 42 | // maxSqidAttempts bounds the retry loop on a UNIQUE violation. Ten |
| 43 | // characters over 31 symbols makes a collision astronomically |
| 44 | // unlikely; this exists so one is handled rather than surprising. |
| 45 | const maxSqidAttempts = 5 |
| 46 | |
| 47 | // newSqid mints a deck's public name. len(sqidAlphabet) is 31, not a |
| 48 | // power of two, so `b % 31` is very slightly biased: 256 % 31 == 8, so |
| 49 | // the first eight symbols come out about 3% more often than the rest. |
| 50 | // Said out loud rather than papered over — at ten characters the bias |
| 51 | // narrows the keyspace by a bounded fraction nowhere near enough to |
| 52 | // make a deck link guessable, and Docs made the same trade. |
| 53 | func newSqid() (string, error) { |
| 54 | raw := make([]byte, sqidLength) |
| 55 | if _, err := rand.Read(raw); err != nil { |
| 56 | return "", fmt.Errorf("slides: generate sqid: %w", err) |
| 57 | } |
| 58 | out := make([]byte, sqidLength) |
| 59 | for i, b := range raw { |
| 60 | out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)] |
| 61 | } |
| 62 | return string(out), nil |
| 63 | } |
| 64 | |
| 65 | // idAlphabet is spec §2.2's "12 lowercase base32 characters" for slide |
| 66 | // and block ids. Thirty-two symbols exactly, so masking a random byte |
| 67 | // with 31 is uniform — unlike the sqid alphabet above, this one has no |
| 68 | // bias to admit to. |
| 69 | const idAlphabet = "abcdefghijklmnopqrstuvwxyz234567" |
| 70 | |
| 71 | // idLength is spec §2.2's twelve. |
| 72 | const idLength = 12 |
| 73 | |
| 74 | // newID mints a slide or block id. The core never draws entropy of its |
| 75 | // own (core/src/ops.rs: on wasm the CRDT library's seed is a constant), |
| 76 | // so every id in a document is minted here and travels in the op. |
| 77 | func newID() (string, error) { |
| 78 | raw := make([]byte, idLength) |
| 79 | if _, err := rand.Read(raw); err != nil { |
| 80 | return "", fmt.Errorf("slides: generate id: %w", err) |
| 81 | } |
| 82 | out := make([]byte, idLength) |
| 83 | for i, b := range raw { |
| 84 | out[i] = idAlphabet[b&31] |
| 85 | } |
| 86 | return string(out), nil |
| 87 | } |
| 88 | |
| 89 | // --- vocabularies --- |
| 90 | |
| 91 | // The rungs of spec §5. Only private and team are offered by a screen |
| 92 | // in this slice: granted needs a way to name people, which does not |
| 93 | // exist yet, but a deck already sitting on that rung stays readable to |
| 94 | // the people its grants name (visibleDecks) and its label renders. |
| 95 | const ( |
| 96 | rungPrivate = "private" |
| 97 | rungGranted = "granted" |
| 98 | rungTeam = "team" |
| 99 | ) |
| 100 | |
| 101 | // settableRungs is what the settings screen offers, in the order it |
| 102 | // offers them. Not every rung there is — see the constants above. |
| 103 | var settableRungs = []string{rungPrivate, rungTeam} |
| 104 | |
| 105 | // themeIDs are the six themes a deck can wear, in the order the picker |
| 106 | // lists them. Identifiers, not copy: each one's visible name is a |
| 107 | // catalog key (slides.theme.<id>). |
| 108 | var themeIDs = []string{"paper", "ink", "sunset", "forest", "slate", "candy"} |
| 109 | |
| 110 | // defaultTheme is what a deck gets when nobody chose — the same value |
| 111 | // the core defaults meta.theme to (core/src/schema.rs's DEFAULT_THEME), |
| 112 | // so the row and the document agree before anything is written. |
| 113 | const defaultTheme = "paper" |
| 114 | |
| 115 | // serverClientID is the Yjs client id every update this process writes |
| 116 | // carries. One document is only ever edited server-side by one writer |
| 117 | // at a time (a Handle is not safe for concurrent use, and every write |
| 118 | // goes through applyOps), so a single fixed id is correct and stable |
| 119 | // across restarts — which a random one would not be. |
| 120 | const serverClientID = 1 |
| 121 | |
| 122 | // actorFor is spec §6.2's actor string for a signed-in person. Every |
| 123 | // write records one. |
| 124 | func actorFor(u User) string { return fmt.Sprintf("person:%d", u.ID) } |
| 125 | |
| 126 | // --- ops --- |
| 127 | |
| 128 | // op is one operation in a batch (spec §7.2, core/src/ops.rs). A map |
| 129 | // rather than a Go type per op: the core owns the vocabulary and |
| 130 | // validates every field of it, so a second declaration here would be a |
| 131 | // copy to keep in step for no checking that isn't already done. What it |
| 132 | // buys is that callers — sample.go especially — read as data. |
| 133 | type op map[string]any |
| 134 | |
| 135 | // opsJSON encodes a batch for core.Handle.Ops. |
| 136 | func opsJSON(ops []op) ([]byte, error) { |
| 137 | b, err := json.Marshal(ops) |
| 138 | if err != nil { |
| 139 | return nil, fmt.Errorf("slides: encode ops: %w", err) |
| 140 | } |
| 141 | return b, nil |
| 142 | } |
| 143 | |
| 144 | // --- the visibility seam --- |
| 145 | |
| 146 | // visibleDecks is the one query every deck read goes through: decks |
| 147 | // viewer owns, decks on the team rung, and decks a grant names them on |
| 148 | // (spec §5). |
| 149 | // |
| 150 | // Not rastrillo's scope.Owned (SKILL.md §3), which is owner-only: a |
| 151 | // deck's audience here is wider than its owner by design, and a helper |
| 152 | // that silently kept filtering to creator-only would make a shared deck |
| 153 | // look deleted rather than broken. Written here so the day grants get a |
| 154 | // screen, this is the one function that changes. |
| 155 | // |
| 156 | // A deck that is not in here does not exist: handlers answer 404, never |
| 157 | // 403, so a stranger holding a sqid cannot learn whether it is real. |
| 158 | // |
| 159 | // The condition is parenthesised in the string rather than left to |
| 160 | // GORM's clause building, because every caller chains another Where |
| 161 | // onto it and an unparenthesised OR would quietly widen to "or this |
| 162 | // deck is on the team rung" — the whole seam defeated by operator |
| 163 | // precedence. |
| 164 | func (a *app) visibleDecks(ctx context.Context, viewer User) *gorm.DB { |
| 165 | g := a.db.G.WithContext(ctx).Model(&Deck{}) |
| 166 | if viewer.ID <= 0 { |
| 167 | // Never reached past RequireSession; refusing outright beats |
| 168 | // trusting every caller to have checked, because the value |
| 169 | // this guards against — 0 — would otherwise match every row a |
| 170 | // backfill had ever left ownerless. |
| 171 | return g.Where("1 = 0") |
| 172 | } |
| 173 | return g.Where( |
| 174 | "(decks.owner_id = ? OR decks.rung = ? OR EXISTS ("+ |
| 175 | "SELECT 1 FROM deck_grants WHERE deck_grants.deck_id = decks.id AND deck_grants.user_id = ?))", |
| 176 | viewer.ID, rungTeam, viewer.ID) |
| 177 | } |
| 178 | |
| 179 | // --- the document --- |
| 180 | |
| 181 | // openDeck instantiates d's Y.Doc: a fresh wasm module, the newest |
| 182 | // snapshot applied, every update after it replayed in seq order, and |
| 183 | // the view that produces. |
| 184 | // |
| 185 | // The handle is the CALLER's to close. One module per request is |
| 186 | // deliberate — an instance is not safe for concurrent use, and a |
| 187 | // long-lived one is a change to make when there is evidence for it. |
| 188 | // |
| 189 | // The snapshot is an accelerator and never the truth (snapshot.go): a |
| 190 | // deck with none, or one whose snapshot row could not be read, opens |
| 191 | // from the plain log and is exactly the same deck, only slower. |
| 192 | func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, error) { |
| 193 | h, err := a.core.NewDoc(ctx, d.Sqid, serverClientID) |
| 194 | if err != nil { |
| 195 | return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err) |
| 196 | } |
| 197 | |
| 198 | snap, updates, err := a.replayPlan(ctx, d) |
| 199 | if err != nil { |
| 200 | h.Close(ctx) |
| 201 | return nil, nil, err |
| 202 | } |
| 203 | var since int64 |
| 204 | retained := 0 |
| 205 | if snap != nil { |
| 206 | since = snap.Seq |
| 207 | retained = 1 |
| 208 | if err := h.Apply(ctx, snap.Bytes); err != nil { |
| 209 | h.Close(ctx) |
| 210 | a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead", |
| 211 | "deck", d.Sqid, "err", err) |
| 212 | h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID) |
| 213 | if err != nil { |
| 214 | return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err) |
| 215 | } |
| 216 | since = 0 |
| 217 | retained = 0 |
| 218 | updates, err = a.replayLog(ctx, d, 0) |
| 219 | if err != nil { |
| 220 | h.Close(ctx) |
| 221 | return nil, nil, err |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | for len(updates) > 0 { |
| 226 | for _, u := range updates { |
| 227 | if u.Seq != since+1 { |
| 228 | h.Close(ctx) |
| 229 | return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1) |
| 230 | } |
| 231 | if retained >= replayCheckpointCadence { |
| 232 | state, err := h.State(ctx) |
| 233 | if err != nil { |
| 234 | h.Close(ctx) |
| 235 | return nil, nil, fmt.Errorf("slides: checkpoint deck %s replay: %w", d.Sqid, err) |
| 236 | } |
| 237 | h.Close(ctx) |
| 238 | h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID) |
| 239 | if err != nil { |
| 240 | return nil, nil, err |
| 241 | } |
| 242 | if err := h.Apply(ctx, state); err != nil { |
| 243 | h.Close(ctx) |
| 244 | return nil, nil, err |
| 245 | } |
| 246 | retained = 1 |
| 247 | } |
| 248 | if err := h.Apply(ctx, u.Bytes); err != nil { |
| 249 | h.Close(ctx) |
| 250 | return nil, nil, fmt.Errorf("slides: replay deck %s update %d: %w", d.Sqid, u.Seq, err) |
| 251 | } |
| 252 | since = u.Seq |
| 253 | retained++ |
| 254 | } |
| 255 | updates, err = a.replayLog(ctx, d, since) |
| 256 | if err != nil { |
| 257 | h.Close(ctx) |
| 258 | return nil, nil, err |
| 259 | } |
| 260 | } |
| 261 | if since != d.Seq { |
| 262 | h.Close(ctx) |
| 263 | return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1) |
| 264 | } |
| 265 | |
| 266 | view, err := h.JSON(ctx) |
| 267 | if err != nil { |
| 268 | h.Close(ctx) |
| 269 | return nil, nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err) |
| 270 | } |
| 271 | parsed, err := deck.Parse(view) |
| 272 | if err != nil { |
| 273 | h.Close(ctx) |
| 274 | return nil, nil, err |
| 275 | } |
| 276 | return h, parsed, nil |
| 277 | } |
| 278 | |
| 279 | // errDeckBusy is applyOps' optimistic-lock failure: somebody else's |
| 280 | // update landed between this one reading Deck.Seq and writing it. The |
| 281 | // batch applied nothing; the caller reopens the deck and tries again. |
| 282 | var errDeckBusy = errors.New("slides: the deck moved while this update was being written") |
| 283 | |
| 284 | // applyOps runs one batch against an open handle and records what it |
| 285 | // produced: the update bytes as the next row of d's log, and d.Seq |
| 286 | // bumped to match — both in one transaction, so a deck can never have |
| 287 | // a seq the log cannot account for. |
| 288 | // |
| 289 | // The seq bump is the optimistic lock (`WHERE id = ? AND seq = ?`): if |
| 290 | // no row matched, another writer got there first and the whole |
| 291 | // transaction rolls back with errDeckBusy. |
| 292 | // |
| 293 | // The (deck_id, seq) unique index is the SAME lock, hit one statement |
| 294 | // earlier — the update row goes in before the bump, so a losing writer |
| 295 | // violates the index before it ever reaches the clause. rastrillo's |
| 296 | // db.Open sets GORM's TranslateError, which makes that violation a |
| 297 | // gorm.ErrDuplicatedKey, and it is translated here into errDeckBusy: |
| 298 | // it is not a bug in the batch, it is the same lost race, and the |
| 299 | // caller's answer to it is the same retry. Without this the loser came |
| 300 | // out as a 500 where docs/api.md promises a 409, and editDeck's retry |
| 301 | // never ran. |
| 302 | // |
| 303 | // One honest wrinkle: h.Ops runs INSIDE the transaction, so a rollback |
| 304 | // leaves the handle holding a change the database does not. That is |
| 305 | // harmless while handles are per-request and discarded after a failure |
| 306 | // — which is every caller today — and is the reason editDeck reopens |
| 307 | // rather than retrying on the same handle. |
| 308 | // |
| 309 | // d.Seq is updated in place on success so a caller applying two batches |
| 310 | // in a row does not have to re-read the row. |
| 311 | // |
| 312 | // rowUpdates is the row's own columns (title, theme, rung — the ones a |
| 313 | // list or a link needs) to change alongside the document, in the SAME |
| 314 | // transaction as the update-log insert. Nil for every caller that only |
| 315 | // touches the document. Merged into the same `WHERE id = ? AND seq = ?` |
| 316 | // Updates call the seq bump already makes: that call is the optimistic |
| 317 | // lock, so folding the row's other columns into it costs nothing extra |
| 318 | // and means the row and the log commit or roll back together — a |
| 319 | // failure applying rowUpdates (a rejected rung, a constraint) now rolls |
| 320 | // back h.Ops' own DeckUpdate row too, rather than leaving the row |
| 321 | // diverged from the document's meta. |
| 322 | func (a *app) applyOps(ctx context.Context, h *core.Handle, d *Deck, actor string, ops []byte, rowUpdates map[string]any) (*deck.Deck, error) { |
| 323 | err := a.db.G.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 324 | update, err := h.Ops(ctx, ops) |
| 325 | if err != nil { |
| 326 | return fmt.Errorf("slides: apply ops to deck %s: %w", d.Sqid, err) |
| 327 | } |
| 328 | |
| 329 | next := d.Seq + 1 |
| 330 | if err := tx.Create(&DeckUpdate{ |
| 331 | DeckID: d.ID, |
| 332 | Seq: next, |
| 333 | Actor: actor, |
| 334 | Bytes: update, |
| 335 | CreatedAt: time.Now().UTC(), |
| 336 | }).Error; err != nil { |
| 337 | if errors.Is(err, gorm.ErrDuplicatedKey) { |
| 338 | // Somebody else's update is already sitting at this |
| 339 | // seq: the lost race, reported as the lost race. |
| 340 | return errDeckBusy |
| 341 | } |
| 342 | return fmt.Errorf("slides: record deck %s update %d: %w", d.Sqid, next, err) |
| 343 | } |
| 344 | |
| 345 | // Named columns off a map, never a bound struct (SKILL.md §4): |
| 346 | // "seq" is always present so the WHERE ... AND seq = ? clause |
| 347 | // above stays the sole optimistic lock, whether or not this |
| 348 | // call is also renaming the deck. |
| 349 | fields := map[string]any{"seq": next} |
| 350 | for k, v := range rowUpdates { |
| 351 | fields[k] = v |
| 352 | } |
| 353 | res := tx.Model(&Deck{}).Where("id = ? AND seq = ?", d.ID, d.Seq). |
| 354 | Updates(fields) |
| 355 | if res.Error != nil { |
| 356 | return fmt.Errorf("slides: bump deck %s seq: %w", d.Sqid, res.Error) |
| 357 | } |
| 358 | if res.RowsAffected != 1 { |
| 359 | return errDeckBusy |
| 360 | } |
| 361 | return nil |
| 362 | }) |
| 363 | if err != nil { |
| 364 | return nil, err |
| 365 | } |
| 366 | |
| 367 | // After the commit, not inside it: reading the view is another set |
| 368 | // of calls into the wasm module, and SQLite has one writer — doing |
| 369 | // it under the transaction would hold that connection for the whole |
| 370 | // round trip and serialise every other write behind it. |
| 371 | raw, err := h.JSON(ctx) |
| 372 | if err != nil { |
| 373 | return nil, fmt.Errorf("slides: read deck %s view: %w", d.Sqid, err) |
| 374 | } |
| 375 | view, err := deck.Parse(raw) |
| 376 | if err != nil { |
| 377 | return nil, err |
| 378 | } |
| 379 | |
| 380 | d.Seq++ |
| 381 | // After the commit, never inside it: a browser told about a seq a |
| 382 | // rolled-back transaction never wrote would fetch it and find |
| 383 | // nothing, and its cursor would sit one ahead of the deck for the |
| 384 | // rest of the session. |
| 385 | a.live.PublishSeq(d.Sqid, d.Seq) |
| 386 | // Also after the commit, and best effort: h now holds exactly the |
| 387 | // document through d.Seq, which is the one thing maybeSnapshot |
| 388 | // cannot check for itself. |
| 389 | a.maybeSnapshot(ctx, *d, h, d.Seq) |
| 390 | return view, nil |
| 391 | } |
| 392 | |
| 393 | // editDeck is applyOps with the handle's whole life around it: open the |
| 394 | // deck, apply, close. One retry on errDeckBusy, from a freshly opened |
| 395 | // document — the batch applied nothing, so replaying it against the |
| 396 | // state that beat us is the correct thing to do, and a second conflict |
| 397 | // means real contention rather than a race worth spinning on. |
| 398 | func (a *app) editDeck(ctx context.Context, d *Deck, actor string, ops []byte, rowUpdates map[string]any) error { |
| 399 | var lastErr error |
| 400 | for attempt := 0; attempt < 2; attempt++ { |
| 401 | h, _, err := a.openDeck(ctx, *d) |
| 402 | if err != nil { |
| 403 | return err |
| 404 | } |
| 405 | _, err = a.applyOps(ctx, h, d, actor, ops, rowUpdates) |
| 406 | h.Close(ctx) |
| 407 | if err == nil { |
| 408 | return nil |
| 409 | } |
| 410 | lastErr = err |
| 411 | if !errors.Is(err, errDeckBusy) { |
| 412 | return err |
| 413 | } |
| 414 | // Re-read the row: our copy's Seq is what just lost. |
| 415 | if err := a.db.G.WithContext(ctx).First(d, d.ID).Error; err != nil { |
| 416 | return fmt.Errorf("slides: reload deck %s: %w", d.Sqid, err) |
| 417 | } |
| 418 | } |
| 419 | return lastErr |
| 420 | } |
| 421 | |
| 422 | // createDeckRow inserts the row, retrying on a sqid collision. The |
| 423 | // document itself is empty at this point — the caller's first op batch |
| 424 | // is what puts anything in it. |
| 425 | func (a *app) createDeckRow(ctx context.Context, owner User, title, theme string) (Deck, error) { |
| 426 | now := time.Now().UTC() |
| 427 | var lastErr error |
| 428 | for attempt := 0; attempt < maxSqidAttempts; attempt++ { |
| 429 | sqid, err := newSqid() |
| 430 | if err != nil { |
| 431 | return Deck{}, err |
| 432 | } |
| 433 | d := Deck{ |
| 434 | Sqid: sqid, |
| 435 | Title: title, |
| 436 | OwnerID: owner.ID, |
| 437 | Rung: rungPrivate, // spec §5: every deck starts private |
| 438 | Theme: theme, |
| 439 | CreatedAt: now, |
| 440 | UpdatedAt: now, |
| 441 | } |
| 442 | err = a.db.G.WithContext(ctx).Create(&d).Error |
| 443 | if err == nil { |
| 444 | return d, nil |
| 445 | } |
| 446 | lastErr = err |
| 447 | if !errors.Is(err, gorm.ErrDuplicatedKey) { |
| 448 | break |
| 449 | } |
| 450 | } |
| 451 | return Deck{}, fmt.Errorf("slides: create deck: %w", lastErr) |
| 452 | } |
| 453 | |
| 454 | // --- request plumbing --- |
| 455 | |
| 456 | // viewer is the signed-in person, or a rendered 500 and false. Past |
| 457 | // RequireSession a failure here means the users row could not be read, |
| 458 | // not that nobody is signed in, so it is an error page and not a |
| 459 | // redirect. |
| 460 | func (a *app) viewer(w http.ResponseWriter, r *http.Request) (User, bool) { |
| 461 | u, outcome := a.currentUser(w, r) |
| 462 | switch outcome { |
| 463 | case sessionOK: |
| 464 | return u, true |
| 465 | case sessionOffboarded: |
| 466 | // currentUser answered for itself: the address is no longer |
| 467 | // admitted, so the person has been signed out and told why |
| 468 | // (auth.go's offboard). Not an internal error, and not a |
| 469 | // second answer written over the one already sent. |
| 470 | return User{}, false |
| 471 | default: |
| 472 | a.fail(w, r, "no current user past the session gate", nil) |
| 473 | return User{}, false |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | // fail logs why with a ref and renders the app's own 500. |
| 478 | func (a *app) fail(w http.ResponseWriter, r *http.Request, what string, err error) { |
| 479 | ref := rastrillo.NewRef() |
| 480 | a.logger.Error("slides: "+what, "err", err, "ref", ref) |
| 481 | a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref) |
| 482 | } |
| 483 | |
| 484 | // notFound is the answer for a deck that is not this person's to see — |
| 485 | // and for one that does not exist. Deliberately the same answer: see |
| 486 | // visibleDecks. |
| 487 | func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| 488 | a.errorPage(w, r, http.StatusNotFound, kindDeck, "") |
| 489 | } |
| 490 | |
| 491 | // deckFor resolves the {sqid} in r to a deck u may read, answering 404 |
| 492 | // itself when there is none. |
| 493 | func (a *app) deckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) { |
| 494 | var d Deck |
| 495 | err := a.visibleDecks(r.Context(), u). |
| 496 | Where("decks.sqid = ?", chi.URLParam(r, "sqid")).Take(&d).Error |
| 497 | switch { |
| 498 | case err == nil: |
| 499 | return d, true |
| 500 | case errors.Is(err, gorm.ErrRecordNotFound): |
| 501 | a.notFound(w, r) |
| 502 | default: |
| 503 | a.fail(w, r, "load deck", err) |
| 504 | } |
| 505 | return Deck{}, false |
| 506 | } |
| 507 | |
| 508 | // ownedDeckFor is deckFor for a write: settings and delete are the |
| 509 | // owner's alone, and a team member who can read the deck gets the same |
| 510 | // 404 as a stranger rather than a 403 that would confirm it exists. |
| 511 | func (a *app) ownedDeckFor(w http.ResponseWriter, r *http.Request, u User) (Deck, bool) { |
| 512 | d, ok := a.deckFor(w, r, u) |
| 513 | if !ok { |
| 514 | return Deck{}, false |
| 515 | } |
| 516 | if d.OwnerID != u.ID { |
| 517 | a.notFound(w, r) |
| 518 | return Deck{}, false |
| 519 | } |
| 520 | return d, true |
| 521 | } |
| 522 | |
| 523 | // --- shared view data --- |
| 524 | |
| 525 | // selectOption is one entry of ui's field-select Options. |
| 526 | type selectOption struct { |
| 527 | Value string |
| 528 | Label string |
| 529 | Selected bool |
| 530 | } |
| 531 | |
| 532 | // choiceOption is one card of ui's choice-field Options. |
| 533 | type choiceOption struct { |
| 534 | Value string |
| 535 | Title string |
| 536 | Desc string |
| 537 | Checked bool |
| 538 | } |
| 539 | |
| 540 | // themeOptions builds the theme picker with current pre-selected. |
| 541 | func themeOptions(r *http.Request, current string) []selectOption { |
| 542 | opts := make([]selectOption, 0, len(themeIDs)) |
| 543 | for _, id := range themeIDs { |
| 544 | opts = append(opts, selectOption{ |
| 545 | Value: id, |
| 546 | Label: rastrillo.T(r, "slides.theme."+id), |
| 547 | Selected: id == current, |
| 548 | }) |
| 549 | } |
| 550 | return opts |
| 551 | } |
| 552 | |
| 553 | // rungOptions builds the rung radio group. Each card is a short title |
| 554 | // plus one explaining line, per choice-field's own contract — "Private" |
| 555 | // on its own does not say who that leaves out. |
| 556 | func rungOptions(r *http.Request, current string) []choiceOption { |
| 557 | opts := make([]choiceOption, 0, len(settableRungs)) |
| 558 | for _, rung := range settableRungs { |
| 559 | opts = append(opts, choiceOption{ |
| 560 | Value: rung, |
| 561 | Title: rastrillo.T(r, "slides.rung."+rung), |
| 562 | Desc: rastrillo.T(r, "slides.rung."+rung+".desc"), |
| 563 | Checked: rung == current, |
| 564 | }) |
| 565 | } |
| 566 | return opts |
| 567 | } |
| 568 | |
| 569 | // rungLabel is the pill's visible text. Always text: state is never |
| 570 | // colour alone. |
| 571 | func rungLabel(r *http.Request, rung string) string { |
| 572 | switch rung { |
| 573 | case rungPrivate, rungGranted, rungTeam: |
| 574 | return rastrillo.T(r, "slides.rung."+rung) |
| 575 | default: |
| 576 | // A rung nothing here knows: name the raw value rather than |
| 577 | // rendering an empty pill, so it is debuggable from the page. |
| 578 | return rung |
| 579 | } |
| 580 | } |
| 581 | |
| 582 | // rungTone tints the pill. Private is the quiet default; team is the |
| 583 | // one worth noticing, because it is the rung that widens who can read. |
| 584 | func rungTone(rung string) string { |
| 585 | if rung == rungTeam { |
| 586 | return "positive" |
| 587 | } |
| 588 | return "neutral" |
| 589 | } |
| 590 | |
| 591 | // validTheme reports whether a submitted theme id is one of the six. |
| 592 | func validTheme(id string) bool { |
| 593 | for _, t := range themeIDs { |
| 594 | if t == id { |
| 595 | return true |
| 596 | } |
| 597 | } |
| 598 | return false |
| 599 | } |
| 600 | |
| 601 | // validRung reports whether a submitted rung is one a screen may set. |
| 602 | func validRung(rung string) bool { |
| 603 | for _, r := range settableRungs { |
| 604 | if r == rung { |
| 605 | return true |
| 606 | } |
| 607 | } |
| 608 | return false |
| 609 | } |
| 610 | |
| 611 | // --- GET / : the deck list --- |
| 612 | |
| 613 | type deckRow struct { |
| 614 | Href string |
| 615 | Title string |
| 616 | Sub string |
| 617 | RungLabel string |
| 618 | RungTone string |
| 619 | } |
| 620 | |
| 621 | type decksPage struct { |
| 622 | shell |
| 623 | Title string |
| 624 | NewHref string |
| 625 | NewLabel string |
| 626 | EmptyTitle string |
| 627 | EmptyBody string |
| 628 | Decks []deckRow |
| 629 | } |
| 630 | |
| 631 | // decks serves GET /: every deck this person may open, most recently |
| 632 | // edited first. |
| 633 | // |
| 634 | // The slide count costs one wasm instance per deck, because the count |
| 635 | // lives in the document and nothing else knows it. That is the honest |
| 636 | // price of not denormalising a number that a peer's update can change |
| 637 | // without this process noticing; it is fine at a small team's deck |
| 638 | // count and it is the first thing to revisit if this screen ever feels |
| 639 | // slow — task 6's hub already holds a live handle per open deck. |
| 640 | func (a *app) decks(w http.ResponseWriter, r *http.Request) { |
| 641 | u, ok := a.viewer(w, r) |
| 642 | if !ok { |
| 643 | return |
| 644 | } |
| 645 | |
| 646 | var rows []Deck |
| 647 | if err := a.visibleDecks(r.Context(), u). |
| 648 | Order("decks.updated_at DESC, decks.id DESC").Find(&rows).Error; err != nil { |
| 649 | a.fail(w, r, "list decks", err) |
| 650 | return |
| 651 | } |
| 652 | |
| 653 | page := decksPage{ |
| 654 | // The app root's <title> is the app's name alone (spec §10). |
| 655 | shell: a.shellFor(r, u, frame{}), |
| 656 | Title: rastrillo.T(r, "slides.decks.title"), |
| 657 | NewHref: "/decks/new", |
| 658 | NewLabel: rastrillo.T(r, "slides.decks.new"), |
| 659 | EmptyTitle: rastrillo.T(r, "slides.decks.empty.title"), |
| 660 | EmptyBody: rastrillo.T(r, "slides.decks.empty.body"), |
| 661 | Decks: make([]deckRow, 0, len(rows)), |
| 662 | } |
| 663 | by := a.lastChangeBy(r.Context(), rows) |
| 664 | for _, d := range rows { |
| 665 | slides, counted := a.slideCount(r.Context(), d) |
| 666 | page.Decks = append(page.Decks, deckRow{ |
| 667 | Href: "/d/" + d.Sqid, |
| 668 | Title: d.Title, |
| 669 | Sub: rowSub(r, slides, counted, d.UpdatedAt, by[d.ID]), |
| 670 | RungLabel: rungLabel(r, d.Rung), |
| 671 | RungTone: rungTone(d.Rung), |
| 672 | }) |
| 673 | } |
| 674 | render(w, "decks", page) |
| 675 | } |
| 676 | |
| 677 | // lastChangeBy answers, for each deck, who made its most recent write, |
| 678 | // as a person reads it: an address for a person, the robot mark plus |
| 679 | // the principal for an agent (agents.go's actorLabels). |
| 680 | // |
| 681 | // Spec §6.2's actor column is what makes this possible at all, and |
| 682 | // spec §7.1 is why it is on the screen: an agent's write is a |
| 683 | // different kind of fact from a person's, and a list that showed them |
| 684 | // identically would be hiding the one thing worth knowing about a deck |
| 685 | // a program has been editing. |
| 686 | // |
| 687 | // One query for every row, not one per row: the subquery finds each |
| 688 | // deck's highest seq and the join reads that row's actor. A failure |
| 689 | // here is logged and the list renders without the line — the list is |
| 690 | // how a person reaches every deck, so it must not go down over a |
| 691 | // caption. |
| 692 | func (a *app) lastChangeBy(ctx context.Context, decks []Deck) map[int64]string { |
| 693 | if len(decks) == 0 { |
| 694 | return nil |
| 695 | } |
| 696 | ids := make([]int64, 0, len(decks)) |
| 697 | for _, d := range decks { |
| 698 | ids = append(ids, d.ID) |
| 699 | } |
| 700 | |
| 701 | var latest []struct { |
| 702 | DeckID int64 |
| 703 | Actor string |
| 704 | } |
| 705 | err := a.db.G.WithContext(ctx).Raw(` |
| 706 | SELECT u.deck_id AS deck_id, u.actor AS actor |
| 707 | FROM deck_updates u |
| 708 | JOIN (SELECT deck_id, MAX(seq) AS seq FROM deck_updates WHERE deck_id IN ? GROUP BY deck_id) m |
| 709 | ON m.deck_id = u.deck_id AND m.seq = u.seq`, ids).Scan(&latest).Error |
| 710 | if err != nil { |
| 711 | a.logger.Error("slides: read the last change on each deck", "err", err) |
| 712 | return nil |
| 713 | } |
| 714 | |
| 715 | actors := make([]string, 0, len(latest)) |
| 716 | for _, row := range latest { |
| 717 | actors = append(actors, row.Actor) |
| 718 | } |
| 719 | labels := a.actorLabels(ctx, actors) |
| 720 | |
| 721 | out := make(map[int64]string, len(latest)) |
| 722 | for _, row := range latest { |
| 723 | if label := labels[row.Actor]; label != "" { |
| 724 | out[row.DeckID] = label |
| 725 | } |
| 726 | } |
| 727 | return out |
| 728 | } |
| 729 | |
| 730 | // slideCount opens d far enough to count its slides. A deck that will |
| 731 | // not open is logged and reported as uncounted rather than failing the |
| 732 | // whole screen: the list is how a person reaches every OTHER deck, so |
| 733 | // one unreadable document must not take the other nine away with it. |
| 734 | func (a *app) slideCount(ctx context.Context, d Deck) (int, bool) { |
| 735 | h, view, err := a.openDeck(ctx, d) |
| 736 | if err != nil { |
| 737 | a.logger.Error("slides: open deck for the list", "deck", d.Sqid, "err", err) |
| 738 | return 0, false |
| 739 | } |
| 740 | defer h.Close(ctx) |
| 741 | return len(view.Slides), true |
| 742 | } |
| 743 | |
| 744 | // rowSub is a list row's meta line: how many slides, when it was last |
| 745 | // edited, and who made that change. An uncounted deck (see slideCount) |
| 746 | // shows the time alone rather than a count that would be a guess, and |
| 747 | // a deck whose last actor could not be resolved shows no name rather |
| 748 | // than a raw "person:7". |
| 749 | func rowSub(r *http.Request, slides int, counted bool, updated time.Time, by string) string { |
| 750 | when := whenWords(r, updated) |
| 751 | sub := rastrillo.Tf(r, "slides.decks.row.sub.plain", "when", when) |
| 752 | if counted { |
| 753 | count := rastrillo.Tf(r, "slides.decks.row.slides", "n", slides) |
| 754 | if slides == 1 { |
| 755 | count = rastrillo.T(r, "slides.decks.row.slides.one") |
| 756 | } |
| 757 | sub = rastrillo.Tf(r, "slides.decks.row.sub", "slides", count, "when", when) |
| 758 | } |
| 759 | if by == "" { |
| 760 | return sub |
| 761 | } |
| 762 | return rastrillo.Tf(r, "slides.decks.row.by", "sub", sub, "who", by) |
| 763 | } |
| 764 | |
| 765 | // whenWords is a day-grained relative phrase, falling back to the date |
| 766 | // itself once "n days ago" stops being easier to read than a date. |
| 767 | // Day granularity on purpose: a deck list is scanned for which deck, |
| 768 | // not for what minute. |
| 769 | func whenWords(r *http.Request, t time.Time) string { |
| 770 | days := int(time.Since(t).Hours() / 24) |
| 771 | switch { |
| 772 | case days <= 0: |
| 773 | return rastrillo.T(r, "slides.when.today") |
| 774 | case days == 1: |
| 775 | return rastrillo.T(r, "slides.when.yesterday") |
| 776 | case days < 7: |
| 777 | return rastrillo.Tf(r, "slides.when.days", "n", days) |
| 778 | default: |
| 779 | return rastrillo.Tf(r, "slides.when.on", "date", t.Format("2 January 2006")) |
| 780 | } |
| 781 | } |
| 782 | |
| 783 | // --- GET /decks/new, POST /decks --- |
| 784 | |
| 785 | type deckNewPage struct { |
| 786 | shell |
| 787 | Title string |
| 788 | Body string |
| 789 | TitleLabel string |
| 790 | TitleValue string |
| 791 | ThemeLabel string |
| 792 | Themes []selectOption |
| 793 | Submit string |
| 794 | CancelHref string |
| 795 | CancelLabel string |
| 796 | Errors map[string]string |
| 797 | } |
| 798 | |
| 799 | func (a *app) newDeck(w http.ResponseWriter, r *http.Request) { |
| 800 | u, ok := a.viewer(w, r) |
| 801 | if !ok { |
| 802 | return |
| 803 | } |
| 804 | render(w, "deck_new", a.newDeckPage(r, u, "", defaultTheme, nil)) |
| 805 | } |
| 806 | |
| 807 | func (a *app) newDeckPage(r *http.Request, u User, title, theme string, errs map[string]string) deckNewPage { |
| 808 | return deckNewPage{ |
| 809 | shell: a.shellFor(r, u, titled(rastrillo.T(r, "slides.decks.new"))), |
| 810 | Title: rastrillo.T(r, "slides.decks.new"), |
| 811 | Body: rastrillo.T(r, "slides.decks.new.body"), |
| 812 | TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| 813 | TitleValue: title, |
| 814 | ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"), |
| 815 | Themes: themeOptions(r, theme), |
| 816 | Submit: rastrillo.T(r, "slides.decks.new.submit"), |
| 817 | CancelHref: "/", |
| 818 | CancelLabel: rastrillo.T(r, "slides.cancel"), |
| 819 | Errors: errs, |
| 820 | } |
| 821 | } |
| 822 | |
| 823 | // createDeck serves POST /decks: the row, then the document's opening |
| 824 | // batch — the title and theme in meta, and one title slide carrying the |
| 825 | // same words, so a new deck opens on something rather than on nothing. |
| 826 | func (a *app) createDeck(w http.ResponseWriter, r *http.Request) { |
| 827 | u, ok := a.viewer(w, r) |
| 828 | if !ok { |
| 829 | return |
| 830 | } |
| 831 | |
| 832 | p := form.Parse(r, form.Field{Name: "title", Required: true}) |
| 833 | title := p.String("title") |
| 834 | theme := r.PostFormValue("theme") |
| 835 | errs := map[string]string{} |
| 836 | if !p.OK() { |
| 837 | // The framework's own message is English built from the field |
| 838 | // name; this app's every visible string is a catalog key, so |
| 839 | // the failure is kept and the sentence replaced. |
| 840 | errs["Title"] = rastrillo.T(r, "slides.deck.title.required") |
| 841 | } |
| 842 | if !validTheme(theme) { |
| 843 | errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid") |
| 844 | } |
| 845 | if len(errs) > 0 { |
| 846 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 847 | render(w, "deck_new", a.newDeckPage(r, u, p.Echo()["title"], theme, errs)) |
| 848 | return |
| 849 | } |
| 850 | |
| 851 | d, err := a.createDeckRow(r.Context(), u, title, theme) |
| 852 | if err != nil { |
| 853 | a.fail(w, r, "create deck", err) |
| 854 | return |
| 855 | } |
| 856 | ops, err := openingOps(title, theme) |
| 857 | if err != nil { |
| 858 | a.fail(w, r, "build the opening ops", err) |
| 859 | return |
| 860 | } |
| 861 | if err := a.editDeck(r.Context(), &d, actorFor(u), ops, nil); err != nil { |
| 862 | a.fail(w, r, "write the opening ops", err) |
| 863 | return |
| 864 | } |
| 865 | http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther) |
| 866 | } |
| 867 | |
| 868 | // openingOps is a brand-new deck's first batch: meta, one flow slide on |
| 869 | // the `title` template, and a level-1 heading carrying the deck's name. |
| 870 | func openingOps(title, theme string) ([]byte, error) { |
| 871 | slide, err := newID() |
| 872 | if err != nil { |
| 873 | return nil, err |
| 874 | } |
| 875 | heading, err := newID() |
| 876 | if err != nil { |
| 877 | return nil, err |
| 878 | } |
| 879 | return opsJSON([]op{ |
| 880 | {"op": "set_meta", "title": title, "theme": theme}, |
| 881 | {"op": "add_slide", "id": slide, "layout": "flow", "template": "title"}, |
| 882 | {"op": "add_block", "slide": slide, "id": heading, "type": "heading", |
| 883 | "props": map[string]any{"level": 1}, "region": "heading", "markdown": title}, |
| 884 | }) |
| 885 | } |
| 886 | |
| 887 | // --- GET/POST /d/{sqid}/settings --- |
| 888 | |
| 889 | type deckSettingsPage struct { |
| 890 | shell |
| 891 | Title string |
| 892 | Action string |
| 893 | TitleLabel string |
| 894 | TitleValue string |
| 895 | ThemeLabel string |
| 896 | Themes []selectOption |
| 897 | RungLegend string |
| 898 | Rungs []choiceOption |
| 899 | Submit string |
| 900 | CancelHref string |
| 901 | CancelLabel string |
| 902 | DeleteHref string |
| 903 | DeleteLabel string |
| 904 | DeleteBody string |
| 905 | BackHref string |
| 906 | BackLabel string |
| 907 | Errors map[string]string |
| 908 | } |
| 909 | |
| 910 | func (a *app) settingsPage(r *http.Request, u User, d Deck, title, theme, rung string, errs map[string]string) deckSettingsPage { |
| 911 | base := "/d/" + d.Sqid |
| 912 | return deckSettingsPage{ |
| 913 | shell: a.shellFor(r, u, onDeck(rastrillo.T(r, "slides.deck.settings"), d.Title)), |
| 914 | Title: rastrillo.T(r, "slides.deck.settings"), |
| 915 | Action: base + "/settings", |
| 916 | TitleLabel: rastrillo.T(r, "slides.deck.title.label"), |
| 917 | TitleValue: title, |
| 918 | ThemeLabel: rastrillo.T(r, "slides.deck.theme.label"), |
| 919 | Themes: themeOptions(r, theme), |
| 920 | RungLegend: rastrillo.T(r, "slides.deck.rung.legend"), |
| 921 | Rungs: rungOptions(r, rung), |
| 922 | Submit: rastrillo.T(r, "slides.deck.settings.submit"), |
| 923 | CancelHref: base, |
| 924 | CancelLabel: rastrillo.T(r, "slides.cancel"), |
| 925 | DeleteHref: base + "/delete", |
| 926 | DeleteLabel: rastrillo.T(r, "slides.deck.delete"), |
| 927 | DeleteBody: rastrillo.T(r, "slides.deck.delete.lead"), |
| 928 | BackHref: base, |
| 929 | BackLabel: rastrillo.T(r, "slides.deck.back"), |
| 930 | Errors: errs, |
| 931 | } |
| 932 | } |
| 933 | |
| 934 | func (a *app) deckSettings(w http.ResponseWriter, r *http.Request) { |
| 935 | u, ok := a.viewer(w, r) |
| 936 | if !ok { |
| 937 | return |
| 938 | } |
| 939 | d, ok := a.ownedDeckFor(w, r, u) |
| 940 | if !ok { |
| 941 | return |
| 942 | } |
| 943 | render(w, "deck_settings", a.settingsPage(r, u, d, d.Title, d.Theme, d.Rung, nil)) |
| 944 | } |
| 945 | |
| 946 | // saveDeckSettings serves POST /d/{sqid}/settings. |
| 947 | // |
| 948 | // Title and theme live in two places — the row the list reads and the |
| 949 | // document everyone edits — so both are written, and both go through |
| 950 | // editDeck's rowUpdates so they land in the SAME transaction as the |
| 951 | // update-log insert applyOps makes. A rejected row write (a rung a |
| 952 | // constraint refuses, say) now rolls the document change back too, |
| 953 | // rather than leaving the row's title/theme diverged from what the |
| 954 | // document's meta says — the earlier two-transaction shape's window. |
| 955 | func (a *app) saveDeckSettings(w http.ResponseWriter, r *http.Request) { |
| 956 | u, ok := a.viewer(w, r) |
| 957 | if !ok { |
| 958 | return |
| 959 | } |
| 960 | d, ok := a.ownedDeckFor(w, r, u) |
| 961 | if !ok { |
| 962 | return |
| 963 | } |
| 964 | |
| 965 | p := form.Parse(r, form.Field{Name: "title", Required: true}) |
| 966 | title := p.String("title") |
| 967 | theme := r.PostFormValue("theme") |
| 968 | rung := r.PostFormValue("rung") |
| 969 | errs := map[string]string{} |
| 970 | if !p.OK() { |
| 971 | errs["Title"] = rastrillo.T(r, "slides.deck.title.required") |
| 972 | } |
| 973 | if !validTheme(theme) { |
| 974 | errs["Theme"] = rastrillo.T(r, "slides.deck.theme.invalid") |
| 975 | } |
| 976 | if !validRung(rung) { |
| 977 | errs["Rung"] = rastrillo.T(r, "slides.deck.rung.invalid") |
| 978 | } |
| 979 | if len(errs) > 0 { |
| 980 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 981 | render(w, "deck_settings", a.settingsPage(r, u, d, p.Echo()["title"], theme, rung, errs)) |
| 982 | return |
| 983 | } |
| 984 | |
| 985 | ops, err := opsJSON([]op{{"op": "set_meta", "title": title, "theme": theme}}) |
| 986 | if err != nil { |
| 987 | a.fail(w, r, "build the settings ops", err) |
| 988 | return |
| 989 | } |
| 990 | rowUpdates := map[string]any{"title": title, "theme": theme, "rung": rung} |
| 991 | if err := a.editDeck(r.Context(), &d, actorFor(u), ops, rowUpdates); err != nil { |
| 992 | a.fail(w, r, "write the settings ops", err) |
| 993 | return |
| 994 | } |
| 995 | http.Redirect(w, r, "/d/"+d.Sqid, http.StatusSeeOther) |
| 996 | } |
| 997 | |
| 998 | // --- GET/POST /d/{sqid}/delete --- |
| 999 | |
| 1000 | type deckDeletePage struct { |
| 1001 | shell |
| 1002 | Title string |
| 1003 | Body string |
| 1004 | Action string |
| 1005 | Label string |
| 1006 | CancelHref string |
| 1007 | CancelLabel string |
| 1008 | BackHref string |
| 1009 | BackLabel string |
| 1010 | } |
| 1011 | |
| 1012 | // confirmDeleteDeck serves GET /d/{sqid}/delete: the destructive |
| 1013 | // action's own URL and its own page, per the app's own convention. No |
| 1014 | // modal, no JavaScript, and a GET that changes nothing. |
| 1015 | func (a *app) confirmDeleteDeck(w http.ResponseWriter, r *http.Request) { |
| 1016 | u, ok := a.viewer(w, r) |
| 1017 | if !ok { |
| 1018 | return |
| 1019 | } |
| 1020 | d, ok := a.ownedDeckFor(w, r, u) |
| 1021 | if !ok { |
| 1022 | return |
| 1023 | } |
| 1024 | base := "/d/" + d.Sqid |
| 1025 | render(w, "deck_delete", deckDeletePage{ |
| 1026 | shell: a.shellFor(r, u, frame{parts: []string{rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title)}, context: d.Title}), |
| 1027 | Title: rastrillo.Tf(r, "slides.deck.delete.title", "title", d.Title), |
| 1028 | Body: rastrillo.T(r, "slides.deck.delete.body"), |
| 1029 | Action: base + "/delete", |
| 1030 | Label: rastrillo.T(r, "slides.deck.delete.confirm"), |
| 1031 | CancelHref: base + "/settings", |
| 1032 | CancelLabel: rastrillo.T(r, "slides.cancel"), |
| 1033 | BackHref: base + "/settings", |
| 1034 | BackLabel: rastrillo.T(r, "slides.deck.settings"), |
| 1035 | }) |
| 1036 | } |
| 1037 | |
| 1038 | // deleteDeck serves POST /d/{sqid}/delete. The row, its whole update |
| 1039 | // log and its grants go in one transaction: a deck's updates without |
| 1040 | // their deck are unreachable bytes nothing will ever replay, and a |
| 1041 | // grant to a deck that is gone is a permission pointing at nothing. |
| 1042 | // |
| 1043 | // The presentations go too, and they are the one part with a second |
| 1044 | // half. A session outliving its deck is a live pairing code and a |
| 1045 | // projector holding a talk that can never move again, so the rows go |
| 1046 | // in the same transaction; but every page watching one is parked on |
| 1047 | // the stream, and the stream only ever says anything because the hub |
| 1048 | // does. So the live sessions are read inside the transaction and told |
| 1049 | // after the commit — never before, because an "ended" for a delete |
| 1050 | // that then rolled back would stop a talk that is still running. |
| 1051 | func (a *app) deleteDeck(w http.ResponseWriter, r *http.Request) { |
| 1052 | u, ok := a.viewer(w, r) |
| 1053 | if !ok { |
| 1054 | return |
| 1055 | } |
| 1056 | d, ok := a.ownedDeckFor(w, r, u) |
| 1057 | if !ok { |
| 1058 | return |
| 1059 | } |
| 1060 | |
| 1061 | var live []Presentation |
| 1062 | err := a.db.G.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error { |
| 1063 | live = nil |
| 1064 | if err := tx.Where("deck_id = ? AND ended_at IS NULL", d.ID). |
| 1065 | Find(&live).Error; err != nil { |
| 1066 | return err |
| 1067 | } |
| 1068 | if err := tx.Where("deck_id = ?", d.ID).Delete(&Presentation{}).Error; err != nil { |
| 1069 | return err |
| 1070 | } |
| 1071 | if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckUpdate{}).Error; err != nil { |
| 1072 | return err |
| 1073 | } |
| 1074 | // The snapshots and the verified-asset rows are derived from |
| 1075 | // the deck and read by nothing but it: a snapshot left behind |
| 1076 | // is a whole copy of the document nothing can reach, and an |
| 1077 | // asset row a verification for a deck that no longer exists. |
| 1078 | if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckSnapshot{}).Error; err != nil { |
| 1079 | return err |
| 1080 | } |
| 1081 | if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckAsset{}).Error; err != nil { |
| 1082 | return err |
| 1083 | } |
| 1084 | // And the upload receipts (models.go's AssetUpload): a receipt |
| 1085 | // is registrable only into the deck it was staged for, so one |
| 1086 | // whose deck is gone is a row nothing can ever spend. |
| 1087 | if err := tx.Where("deck_id = ?", d.ID).Delete(&AssetUpload{}).Error; err != nil { |
| 1088 | return err |
| 1089 | } |
| 1090 | if err := tx.Where("deck_id = ?", d.ID).Delete(&DeckGrant{}).Error; err != nil { |
| 1091 | return err |
| 1092 | } |
| 1093 | // The share links go too. A link outliving its deck is a live |
| 1094 | // URL that can only ever answer 404, and leaving one behind |
| 1095 | // would mean the one row in this database that grants |
| 1096 | // unauthenticated read is also the one nothing cleans up. |
| 1097 | if err := tx.Where("deck_id = ?", d.ID).Delete(&ShareLink{}).Error; err != nil { |
| 1098 | return err |
| 1099 | } |
| 1100 | return tx.Where("id = ? AND owner_id = ?", d.ID, u.ID).Delete(&Deck{}).Error |
| 1101 | }) |
| 1102 | if err != nil { |
| 1103 | a.fail(w, r, "delete deck", err) |
| 1104 | return |
| 1105 | } |
| 1106 | // Every projector, phone and follower watching one of this deck's |
| 1107 | // talks, told at once that it is over. |
| 1108 | for _, p := range live { |
| 1109 | a.hub.Publish(p.Sqid, present.Event{Ended: true}) |
| 1110 | } |
| 1111 | http.Redirect(w, r, "/", http.StatusSeeOther) |
| 1112 | } |
| 1113 | |