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  }