eintopf.info@v0.13.16/service/revent/revent.go (about) 1 // Copyright (C) 2022 The Eintopf authors 2 // 3 // This program is free software: you can redistribute it and/or modify 4 // it under the terms of the GNU Affero General Public License as 5 // published by the Free Software Foundation, either version 3 of the 6 // License, or (at your option) any later version. 7 // 8 // This program is distributed in the hope that it will be useful, 9 // but WITHOUT ANY WARRANTY; without even the implied warranty of 10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 // GNU Affero General Public License for more details. 12 // 13 // You should have received a copy of the GNU Affero General Public License 14 // along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16 // package revent implement repeating events with a very flexible interval type. 17 // 18 // Example intervals are: 19 // - every day 20 // - every second day 21 // - every monday 22 // - every second tuesday 23 // - every first monday of a month 24 // - every second tuesday of a month 25 // - every first monday of every second month 26 // 27 // This gives three categories of intervals: 28 // - DayInterval: every [1..7] day, starting at [date] 29 // - WeekInterval: every [1..] [weekday] of week 30 // - MonthInterval: every [1..4] [weekday] a month 31 package revent 32 33 import ( 34 "context" 35 "fmt" 36 "strings" 37 "time" 38 39 "eintopf.info/internal/crud" 40 "eintopf.info/internal/xerror" 41 "eintopf.info/service/auth" 42 "eintopf.info/service/event" 43 ) 44 45 type NewRepeatingEvent struct { 46 Name string `json:"name"` 47 Organizers []string `json:"organizers"` 48 Location string `json:"location"` 49 Location2 string `json:"location2"` 50 Description string `json:"description"` 51 Intervals []Interval `json:"intervals"` 52 Start time.Time `json:"start"` 53 End time.Time `json:"end"` 54 Tags []string `json:"tags"` 55 Image string `json:"image"` 56 OwnedBy []string `json:"ownedBy"` 57 } 58 59 // IsOwned returns true if the id is in the OwnedBy field. 60 func (r *NewRepeatingEvent) IsOwned(id string) bool { 61 owned := false 62 for _, owner := range r.OwnedBy { 63 if owner == id { 64 owned = true 65 } 66 } 67 return owned 68 } 69 70 func RepeatingEventFromNewRepeatingEvent(newRepeatingEvent *NewRepeatingEvent, id string) *RepeatingEvent { 71 return &RepeatingEvent{ 72 ID: id, 73 Deactivated: false, 74 Name: newRepeatingEvent.Name, 75 Organizers: newRepeatingEvent.Organizers, 76 Location: newRepeatingEvent.Location, 77 Location2: newRepeatingEvent.Location2, 78 Description: newRepeatingEvent.Description, 79 Intervals: newRepeatingEvent.Intervals, 80 Start: newRepeatingEvent.Start, 81 End: newRepeatingEvent.End, 82 Image: newRepeatingEvent.Image, 83 Tags: newRepeatingEvent.Tags, 84 OwnedBy: newRepeatingEvent.OwnedBy, 85 } 86 } 87 88 type RepeatingEvent struct { 89 ID string `json:"id" db:"id"` 90 Deactivated bool `json:"deactivated" db:"deactivated"` 91 Name string `json:"name" db:"name"` 92 Organizers []string `json:"organizers" db:"organizers"` 93 Location string `json:"location" db:"location"` 94 Location2 string `json:"location2" db:"location2"` 95 Description string `json:"description" db:"description"` 96 Intervals []Interval `json:"intervals" db:"intervals"` 97 Start time.Time `json:"start" db:"start"` 98 End time.Time `json:"end" db:"end"` 99 Tags []string `json:"tags" db:"tags"` 100 Image string `json:"image" db:"image"` 101 OwnedBy []string `json:"ownedBy" db:"ownedBy"` 102 } 103 104 func (r RepeatingEvent) Identifier() string { return r.ID } 105 106 // IsOwned returns true if the id is in the OwnedBy field. 107 func (r *RepeatingEvent) IsOwned(id string) bool { 108 owned := false 109 for _, owner := range r.OwnedBy { 110 if owner == id { 111 owned = true 112 } 113 } 114 return owned 115 } 116 117 // IntervalType defines the type of the interval (day, week or month) 118 type IntervalType string 119 120 const ( 121 IntervalDay = IntervalType("day") 122 IntervalWeek = IntervalType("week") 123 IntervalMonth = IntervalType("month") 124 ) 125 126 type Interval struct { 127 // Type changes the meaning od Interval an WeekDay. 128 Type IntervalType `json:"type" db:"type"` 129 // Interval used for day, week and month 130 Interval int `json:"interval" db:"interval"` 131 // WeekDay is a week day, with a range of 0 (sunday) to 7 (saturday). 132 WeekDay time.Weekday `json:"weekDay" db:"weekDay"` 133 } 134 135 // GenerateDates generates a list of dates, in a given date range using the 136 // defined interval. 137 func (i *Interval) GenerateDates(start time.Time, end time.Time) ([]time.Time, error) { 138 if start.After(end) { 139 return nil, fmt.Errorf("start must be before end: start %s end %s", start, end) 140 } 141 dates := []time.Time{} 142 143 switch i.Type { 144 case IntervalDay: 145 current := start 146 for { 147 if current.After(end) { 148 return dates, nil 149 } 150 151 dates = append(dates, current) 152 current = current.AddDate(0, 0, i.Interval) 153 } 154 case IntervalWeek: 155 current := i.findWeekStart(start) 156 for { 157 if current.After(end) { 158 return dates, nil 159 } 160 161 dates = append(dates, current) 162 current = current.AddDate(0, 0, i.Interval*7) 163 } 164 case IntervalMonth: 165 current := i.findMonthStart(start) 166 for { 167 if current.After(end) { 168 return dates, nil 169 } 170 171 dates = append(dates, current) 172 current = time.Date(current.Year(), current.Month()+1, 1, current.Hour(), current.Minute(), current.Second(), 0, current.Location()) 173 current = i.findMonthStart(current) 174 } 175 default: 176 return nil, fmt.Errorf("invalid interval type: %s", i.Type) 177 } 178 } 179 180 func normalizeDate(date time.Time) time.Time { 181 return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) 182 } 183 184 func (i *Interval) findWeekStart(date time.Time) time.Time { 185 for { 186 if i.isWeekStart(date) { 187 return date 188 } 189 date = date.AddDate(0, 0, 1) 190 } 191 } 192 193 func (i *Interval) isWeekStart(date time.Time) bool { 194 return date.Weekday() == i.WeekDay 195 } 196 197 func (i *Interval) findMonthStart(date time.Time) time.Time { 198 weekStart := i.findWeekStart(date) 199 return weekStart.AddDate(0, 0, 7*(i.Interval-1)) 200 } 201 202 // ID returns a repeating event id in the form of: 203 // 204 // revent:<id> 205 func ID(id string) string { 206 return fmt.Sprintf("revent:%s", id) 207 } 208 209 // ParseID parses a repeating event id. The second return value indicates if the 210 // given id is a valid repeating event id. 211 func ParseID(id string) (string, bool) { 212 if !strings.HasPrefix(id, "revent:") { 213 return "", false 214 } 215 return strings.TrimPrefix(id, "revent:"), true 216 } 217 218 // -go:generate go run github.com/petergtz/pegomock/pegomock generate eintopf.info/service/revent Service --output=../../internal/mock/revent_service.go --package=mock --mock-name=RepeatingEventService 219 type Service interface { 220 Storer 221 222 GenerateEvents(ctx context.Context, id string, start, end time.Time) ([]*event.Event, error) 223 } 224 225 // Storer defines a service for CRUD operations on the event model. 226 type Storer interface { 227 Create(ctx context.Context, newEvent *NewRepeatingEvent) (*RepeatingEvent, error) 228 Update(ctx context.Context, event *RepeatingEvent) (*RepeatingEvent, error) 229 Delete(ctx context.Context, id string) error 230 FindByID(ctx context.Context, id string) (*RepeatingEvent, error) 231 Find(ctx context.Context, params *crud.FindParams[FindFilters]) ([]*RepeatingEvent, int, error) 232 } 233 234 // SortOrder defines the order of sorting. 235 type SortOrder string 236 237 // Possible values for SortOrder. 238 const ( 239 OrderAsc = SortOrder("ASC") 240 OrderDesc = SortOrder("DESC") 241 ) 242 243 // FindFilters defines the possible filters for the find method. 244 type FindFilters struct { 245 ID *string `json:"id,omitempty"` 246 Deactivated *bool `json:"deactivated,omitempty"` 247 Published *bool `json:"published,omitempty"` 248 Parent *string `json:"parent,omitempty"` 249 Name *string `json:"name,omitempty"` 250 Organizers []string `json:"organizers,omitempty"` 251 Location *string `json:"location,omitempty"` 252 Location2 *string `json:"location2,omitempty"` 253 Description *string `json:"description,omitempty"` 254 Start *time.Time `json:"start,omitempty"` 255 End *time.Time `json:"end,omitempty"` 256 Tags []string `json:"tags,omitempty"` 257 OwnedBy []string `json:"ownedBy,omitempty"` 258 } 259 260 // NewService returns a new event service. 261 func NewService(store Storer, eventService event.Storer) Service { 262 return &service{store: store, eventService: eventService} 263 } 264 265 type service struct { 266 store Storer 267 eventService event.Storer 268 } 269 270 func (s *service) Create(ctx context.Context, newRepeatingEvent *NewRepeatingEvent) (*RepeatingEvent, error) { 271 id, err := auth.UserIDFromContext(ctx) 272 if err != nil { 273 return nil, err 274 } 275 if !newRepeatingEvent.IsOwned(id) { 276 newRepeatingEvent.OwnedBy = append(newRepeatingEvent.OwnedBy, id) 277 } 278 if newRepeatingEvent.Name == "" { 279 return nil, xerror.BadInputError{Err: fmt.Errorf("empty name")} 280 } 281 if len(newRepeatingEvent.Intervals) == 0 { 282 return nil, xerror.BadInputError{Err: fmt.Errorf("no intervals")} 283 } 284 return s.store.Create(ctx, newRepeatingEvent) 285 } 286 287 func (s *service) Update(ctx context.Context, repeatingEvent *RepeatingEvent) (*RepeatingEvent, error) { 288 if repeatingEvent.Name == "" { 289 return nil, xerror.BadInputError{Err: fmt.Errorf("empty name")} 290 } 291 if len(repeatingEvent.Intervals) == 0 { 292 return nil, xerror.BadInputError{Err: fmt.Errorf("no intervals")} 293 } 294 return s.store.Update(ctx, repeatingEvent) 295 } 296 297 func (s *service) Delete(ctx context.Context, id string) error { 298 return s.store.Delete(ctx, id) 299 } 300 301 func (s *service) FindByID(ctx context.Context, id string) (*RepeatingEvent, error) { 302 return s.store.FindByID(ctx, id) 303 } 304 305 func (s *service) Find(ctx context.Context, params *crud.FindParams[FindFilters]) ([]*RepeatingEvent, int, error) { 306 if params != nil && params.Filters != nil && params.Filters.OwnedBy != nil { 307 if len(params.Filters.OwnedBy) == 1 && params.Filters.OwnedBy[0] == "self" { 308 id, err := auth.UserIDFromContext(ctx) 309 if err == nil { 310 params.Filters.OwnedBy = []string{id} 311 } 312 } 313 } 314 return s.store.Find(ctx, params) 315 } 316 317 func (s *service) GenerateEvents(ctx context.Context, id string, start, end time.Time) ([]*event.Event, error) { 318 repeatingEvent, err := s.FindByID(ctx, id) 319 if err != nil { 320 return nil, err 321 } 322 323 parentID := ID(repeatingEvent.ID) 324 existingEvents, _, err := s.eventService.Find(ctx, &crud.FindParams[event.FindFilters]{ 325 Filters: &event.FindFilters{ 326 Parent: &parentID, 327 }, 328 }) 329 if err != nil { 330 return nil, err 331 } 332 333 newEvents, err := GenerateEvents(repeatingEvent, existingEvents, start, end) 334 if err != nil { 335 return nil, err 336 } 337 338 events := []*event.Event{} 339 for _, newEvent := range newEvents { 340 e, err := s.eventService.Create(ctx, newEvent) 341 if err != nil { 342 return nil, err 343 } 344 events = append(events, e) 345 } 346 347 return events, nil 348 } 349 350 func GenerateEvents(repeatingEvent *RepeatingEvent, existingEvents []*event.Event, start, end time.Time) ([]*event.NewEvent, error) { 351 if len(repeatingEvent.Intervals) == 0 { 352 return nil, fmt.Errorf("repeating event has no intervals") 353 } 354 355 newEvents := []*event.NewEvent{} 356 for _, interval := range repeatingEvent.Intervals { 357 dates, err := interval.GenerateDates(start, end) 358 if err != nil { 359 return nil, xerror.BadInputError{Err: err} 360 } 361 for _, date := range dates { 362 eventExists := false 363 for _, existingEvent := range existingEvents { 364 y1, m1, d1 := date.Date() 365 y2, m2, d2 := existingEvent.Start.Date() 366 if y1 == y2 && m1 == m2 && d1 == d2 { 367 eventExists = true 368 break 369 } 370 } 371 // Skip events, that exist already, to prevent generating them twice. 372 // In order to regenerate the event, the existing one needs to be 373 // deleted first. 374 if eventExists { 375 continue 376 } 377 378 ownedBy := make([]string, len(repeatingEvent.OwnedBy)) 379 copy(ownedBy, repeatingEvent.OwnedBy) 380 381 tags := make([]string, len(repeatingEvent.Tags)) 382 copy(tags, repeatingEvent.Tags) 383 384 startEvent := time.Date(date.Year(), date.Month(), date.Day(), repeatingEvent.Start.Hour(), repeatingEvent.Start.Minute(), 0, 0, date.Location()) 385 endEvent := startEvent.Add(repeatingEvent.End.Sub(repeatingEvent.Start)) 386 387 newEvents = append(newEvents, &event.NewEvent{ 388 Published: true, 389 Parent: ID(repeatingEvent.ID), 390 ParentListed: false, 391 Name: repeatingEvent.Name, 392 Organizers: repeatingEvent.Organizers, 393 Location: repeatingEvent.Location, 394 Location2: repeatingEvent.Location2, 395 Description: repeatingEvent.Description, 396 Start: startEvent, 397 End: &endEvent, 398 Image: repeatingEvent.Image, 399 Tags: tags, 400 OwnedBy: ownedBy, 401 }) 402 } 403 } 404 405 return newEvents, nil 406 }