| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "time" |
| 7 | |
| 8 | "gorm.io/gorm" |
| 9 | ) |
| 10 | |
| 11 | // The sweep's booking half: expiring holds, forgetting IPs, and the two |
| 12 | // NextDueAt sources those imply (booking spec §8). |
| 13 | |
| 14 | // ExpireHolds moves every lapsed hold to expired, soft-deletes its |
| 15 | // tentative event, drops its reminder rows and forgets any mail it was |
| 16 | // owed. Bounded to limit rows a pass; the rest come due on the next |
| 17 | // wake, which NextDueAt already points at. |
| 18 | func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error) { |
| 19 | var lapsed []Booking |
| 20 | err := s.g.WithContext(ctx). |
| 21 | Where("state = ? AND hold_expires_at <= ?", BookingHeld, now.Unix()). |
| 22 | Order("hold_expires_at ASC").Limit(limit).Find(&lapsed).Error |
| 23 | if err != nil { |
| 24 | return 0, fmt.Errorf("calendar: find lapsed holds: %w", err) |
| 25 | } |
| 26 | n := 0 |
| 27 | for _, b := range lapsed { |
| 28 | err := s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 29 | res := tx.Model(&Booking{}). |
| 30 | Where("id = ? AND state = ? AND hold_expires_at <= ?", b.ID, BookingHeld, now.Unix()). |
| 31 | Updates(map[string]any{"state": BookingExpired, "updated_at": now.UTC()}) |
| 32 | if res.Error != nil { |
| 33 | return fmt.Errorf("calendar: expire hold: %w", res.Error) |
| 34 | } |
| 35 | if res.RowsAffected == 0 { |
| 36 | return nil // confirmed under us; not ours to expire |
| 37 | } |
| 38 | n++ |
| 39 | return retireHeldEvent(tx, b.EventID, b.ID, now) |
| 40 | }) |
| 41 | if err != nil { |
| 42 | return n, err |
| 43 | } |
| 44 | } |
| 45 | return n, nil |
| 46 | } |
| 47 | |
| 48 | // retireHeldEvent is what an expired or abandoned hold leaves behind: |
| 49 | // no event, no reminder rows, no owed mail. |
| 50 | func retireHeldEvent(tx *gorm.DB, eventID, bookingID int64, now time.Time) error { |
| 51 | if err := tx.Model(&Event{}).Where("id = ? AND deleted_at IS NULL", eventID). |
| 52 | Update("deleted_at", now.UTC()).Error; err != nil { |
| 53 | return fmt.Errorf("calendar: retire held event: %w", err) |
| 54 | } |
| 55 | if err := deleteReminderRowsFor(tx, eventID); err != nil { |
| 56 | return err |
| 57 | } |
| 58 | return deleteUnsentMail(tx, bookingID) |
| 59 | } |
| 60 | |
| 61 | // deleteReminderRowsFor drops the queue rows of every reminder on an |
| 62 | // event. The rules stay: a cancelled meeting's reminder preference is |
| 63 | // still the person's, and a queue row is what fires. |
| 64 | func deleteReminderRowsFor(tx *gorm.DB, eventID int64) error { |
| 65 | err := tx.Exec(`DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`, eventID).Error |
| 66 | if err != nil { |
| 67 | return fmt.Errorf("calendar: drop reminder rows: %w", err) |
| 68 | } |
| 69 | return nil |
| 70 | } |
| 71 | |
| 72 | // ForgetOldIPs nulls the IP fingerprint of bookings past ip_forget_at. |
| 73 | // The fingerprint's purpose — the abuse caps — ends with the attempt |
| 74 | // window; a permanent network-linkage column is a leak the estate does |
| 75 | // not otherwise have. |
| 76 | func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error) { |
| 77 | var ids []int64 |
| 78 | err := s.g.WithContext(ctx).Model(&Booking{}). |
| 79 | Where("ip_fp IS NOT NULL AND ip_forget_at <= ?", now.Unix()). |
| 80 | Order("ip_forget_at ASC").Limit(limit).Pluck("id", &ids).Error |
| 81 | if err != nil { |
| 82 | return 0, fmt.Errorf("calendar: find forgettable ips: %w", err) |
| 83 | } |
| 84 | if len(ids) == 0 { |
| 85 | return 0, nil |
| 86 | } |
| 87 | res := s.g.WithContext(ctx).Model(&Booking{}).Where("id IN ?", ids).Update("ip_fp", nil) |
| 88 | if res.Error != nil { |
| 89 | return 0, fmt.Errorf("calendar: forget ips: %w", res.Error) |
| 90 | } |
| 91 | return int(res.RowsAffected), nil |
| 92 | } |
| 93 | |
| 94 | // NextHoldExpiry is the earliest lapse among open holds. |
| 95 | func (s *Store) NextHoldExpiry(ctx context.Context) time.Time { |
| 96 | var at *int64 |
| 97 | err := s.g.WithContext(ctx).Raw(`SELECT MIN(hold_expires_at) FROM bookings WHERE state = ?`, BookingHeld).Scan(&at).Error |
| 98 | if err != nil || at == nil { |
| 99 | return time.Time{} |
| 100 | } |
| 101 | return time.Unix(*at, 0) |
| 102 | } |
| 103 | |
| 104 | // NextIPForget is the earliest fingerprint still to be forgotten. |
| 105 | func (s *Store) NextIPForget(ctx context.Context) time.Time { |
| 106 | var at *int64 |
| 107 | err := s.g.WithContext(ctx).Raw(`SELECT MIN(ip_forget_at) FROM bookings WHERE ip_fp IS NOT NULL`).Scan(&at).Error |
| 108 | if err != nil || at == nil { |
| 109 | return time.Time{} |
| 110 | } |
| 111 | return time.Unix(*at, 0) |
| 112 | } |
| 113 | |
| 114 | // HoldCounts is what the abuse caps compare against (booking spec §7.1). |
| 115 | type HoldCounts struct { |
| 116 | OpenIP, OpenPage, OpenAddress int |
| 117 | AttemptsIP, AttemptsAddress int |
| 118 | } |
| 119 | |
| 120 | // The caps themselves. |
| 121 | const ( |
| 122 | MaxOpenHoldsPerIP = 5 |
| 123 | MaxOpenHoldsPerPage = 20 |
| 124 | MaxOpenHoldsPerAddress = 2 |
| 125 | MaxAttemptsPerIP = 20 |
| 126 | MaxAttemptsPerAddress = 6 |
| 127 | attemptWindow = 24 * time.Hour |
| 128 | ) |
| 129 | |
| 130 | // HoldCountsFor reads the five counts for one prospective hold. Every |
| 131 | // state counts toward attempts — that is why a hold whose mail failed |
| 132 | // is a row and not a rollback. |
| 133 | func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) { |
| 134 | var c HoldCounts |
| 135 | open := func(where string, args ...any) (int, error) { |
| 136 | var n int64 |
| 137 | err := s.g.WithContext(ctx).Model(&Booking{}). |
| 138 | Where("state = ? AND hold_expires_at > ?", BookingHeld, now.Unix()). |
| 139 | Where(where, args...).Count(&n).Error |
| 140 | return int(n), err |
| 141 | } |
| 142 | attempts := func(where string, args ...any) (int, error) { |
| 143 | var n int64 |
| 144 | err := s.g.WithContext(ctx).Model(&Booking{}). |
| 145 | Where("created_at > ?", now.Add(-attemptWindow).UTC()). |
| 146 | Where(where, args...).Count(&n).Error |
| 147 | return int(n), err |
| 148 | } |
| 149 | var err error |
| 150 | if c.OpenIP, err = open("ip_fp = ?", ipFP); err != nil { |
| 151 | return c, fmt.Errorf("calendar: count holds: %w", err) |
| 152 | } |
| 153 | if c.OpenPage, err = open("page_id = ?", pageID); err != nil { |
| 154 | return c, fmt.Errorf("calendar: count holds: %w", err) |
| 155 | } |
| 156 | if c.OpenAddress, err = open("booker_address_fp = ?", addressFP); err != nil { |
| 157 | return c, fmt.Errorf("calendar: count holds: %w", err) |
| 158 | } |
| 159 | if c.AttemptsIP, err = attempts("ip_fp = ?", ipFP); err != nil { |
| 160 | return c, fmt.Errorf("calendar: count attempts: %w", err) |
| 161 | } |
| 162 | if c.AttemptsAddress, err = attempts("booker_address_fp = ?", addressFP); err != nil { |
| 163 | return c, fmt.Errorf("calendar: count attempts: %w", err) |
| 164 | } |
| 165 | return c, nil |
| 166 | } |
| 167 | |
| 168 | // overCap reports whether any cap refuses this hold. |
| 169 | func (c HoldCounts) overCap() bool { |
| 170 | return c.OpenIP >= MaxOpenHoldsPerIP || c.OpenPage >= MaxOpenHoldsPerPage || |
| 171 | c.OpenAddress >= MaxOpenHoldsPerAddress || |
| 172 | c.AttemptsIP >= MaxAttemptsPerIP || c.AttemptsAddress >= MaxAttemptsPerAddress |
| 173 | } |
| 174 | |