diff --git a/apiserver/model_configuration.go b/apiserver/model_configuration.go index 3e164c7..f161447 100644 --- a/apiserver/model_configuration.go +++ b/apiserver/model_configuration.go @@ -38,6 +38,12 @@ type Configuration struct { // Minutes after which the booking will be automatically cancelled when no one is present. NoShowMins int32 `json:"noShowMins,omitempty"` + + // Minutes after which the booking will be automatically cancelled when no one is present. + AdHocBookAfterMins int32 `json:"adHocBookAfterMins,omitempty"` + + // Minutes after which the booking will be automatically cancelled when no one is present. + AdHocBookForMins int32 `json:"adHocBookForMins,omitempty"` } // AssertConfigurationRequired checks if the required fields are not zero-ed diff --git a/app.go b/app.go index 758334b..8ba9673 100644 --- a/app.go +++ b/app.go @@ -49,6 +49,14 @@ func manageOccupancy() { return // Error is handled in the function itself. } } + if config.AdHocBookAfterMins >= 0 { + if err := recordPresenceAdHoc(config); err != nil { + return // Error is handled in the function itself. + } + if err := processAdHocBooking(); err != nil { + return // Error is handled in the function itself. + } + } if err := updateOccupancy(); err != nil { return // Error is handled in the function itself. } @@ -75,6 +83,23 @@ func recordPresenceNoShow() error { return nil } +func recordPresenceAdHoc(config apiserver.Configuration) error { + minimumMinsLeft := config.AdHocBookForMins - config.AdHocBookAfterMins + since := time.Now() + until := since.Add(time.Duration(minimumMinsLeft) * time.Minute) + unbookedAssets, err := conf.GetUnbookedAssetIDs(context.Background(), since, until) + if err != nil { + log.Error("conf", "getting booked asset IDs for recording ad-hoc presence: %v", err) + return err + } + for _, assetID := range unbookedAssets { + if err := recordPresenceForAsset(assetID, nil); err != nil { + return err + } + } + return nil +} + func recordPresenceForAsset(assetID int32, bookingID *int64) error { data, r, err := client.NewClient().DataAPI.GetData(client.AuthenticationContext()). ParentAssetId(assetID). @@ -156,6 +181,39 @@ func processNoShow() error { return nil } +func processAdHocBooking() error { + ctx := context.Background() + + adHocBookings, err := conf.GetRecentlyOccupiedAssets(ctx) + if err != nil { + log.Error("conf", "getting ad-hoc bookings: %v", err) + return err + } + for _, assetToBook := range adHocBookings { + bs := apiservices.NewBookingAPIService() + // TODO: Find a better place for the book implementation, here we should not call apiservices. + booking := apiserver.BookingRequest{ + AssetIds: []int32{assetToBook.AssetID}, + OrganizerID: "", + Start: assetToBook.Start.Format(time.RFC3339), + End: assetToBook.End.Format(time.RFC3339), + } + _, err := bs.BookingsPost(ctx, booking, "eliona") + if err != nil { + log.Error("apiservices", "ad-hoc booking asset %v: %v", assetToBook.AssetID, err) + return err + } + } + if len(adHocBookings) > 0 { + log.Debug("app", "Booked %v ad-hoc bookings", len(adHocBookings)) + } + if err := conf.CleanupPresenceLogs(ctx); err != nil { + log.Error("conf", "cleaning up old presence: %v", err) + return err + } + return nil +} + func updateOccupancy() error { ctx := context.Background() since := time.Now() diff --git a/appdb/configuration.go b/appdb/configuration.go index 3bb980e..f026fd9 100644 --- a/appdb/configuration.go +++ b/appdb/configuration.go @@ -33,6 +33,8 @@ type Configuration struct { EndBookableHours int32 `boil:"end_bookable_hours" json:"end_bookable_hours" toml:"end_bookable_hours" yaml:"end_bookable_hours"` EndBookableMins int32 `boil:"end_bookable_mins" json:"end_bookable_mins" toml:"end_bookable_mins" yaml:"end_bookable_mins"` NoShowMins int32 `boil:"no_show_mins" json:"no_show_mins" toml:"no_show_mins" yaml:"no_show_mins"` + AdHocBookAfterMins int32 `boil:"ad_hoc_book_after_mins" json:"ad_hoc_book_after_mins" toml:"ad_hoc_book_after_mins" yaml:"ad_hoc_book_after_mins"` + AdHocBookForMins int32 `boil:"ad_hoc_book_for_mins" json:"ad_hoc_book_for_mins" toml:"ad_hoc_book_for_mins" yaml:"ad_hoc_book_for_mins"` R *configurationR `boil:"-" json:"-" toml:"-" yaml:"-"` L configurationL `boil:"-" json:"-" toml:"-" yaml:"-"` @@ -49,6 +51,8 @@ var ConfigurationColumns = struct { EndBookableHours string EndBookableMins string NoShowMins string + AdHocBookAfterMins string + AdHocBookForMins string }{ ID: "id", StartBookableHours: "start_bookable_hours", @@ -60,6 +64,8 @@ var ConfigurationColumns = struct { EndBookableHours: "end_bookable_hours", EndBookableMins: "end_bookable_mins", NoShowMins: "no_show_mins", + AdHocBookAfterMins: "ad_hoc_book_after_mins", + AdHocBookForMins: "ad_hoc_book_for_mins", } var ConfigurationTableColumns = struct { @@ -73,6 +79,8 @@ var ConfigurationTableColumns = struct { EndBookableHours string EndBookableMins string NoShowMins string + AdHocBookAfterMins string + AdHocBookForMins string }{ ID: "configuration.id", StartBookableHours: "configuration.start_bookable_hours", @@ -84,6 +92,8 @@ var ConfigurationTableColumns = struct { EndBookableHours: "configuration.end_bookable_hours", EndBookableMins: "configuration.end_bookable_mins", NoShowMins: "configuration.no_show_mins", + AdHocBookAfterMins: "configuration.ad_hoc_book_after_mins", + AdHocBookForMins: "configuration.ad_hoc_book_for_mins", } // Generated where @@ -145,6 +155,8 @@ var ConfigurationWhere = struct { EndBookableHours whereHelperint32 EndBookableMins whereHelperint32 NoShowMins whereHelperint32 + AdHocBookAfterMins whereHelperint32 + AdHocBookForMins whereHelperint32 }{ ID: whereHelperint64{field: "\"booking\".\"configuration\".\"id\""}, StartBookableHours: whereHelperint32{field: "\"booking\".\"configuration\".\"start_bookable_hours\""}, @@ -156,6 +168,8 @@ var ConfigurationWhere = struct { EndBookableHours: whereHelperint32{field: "\"booking\".\"configuration\".\"end_bookable_hours\""}, EndBookableMins: whereHelperint32{field: "\"booking\".\"configuration\".\"end_bookable_mins\""}, NoShowMins: whereHelperint32{field: "\"booking\".\"configuration\".\"no_show_mins\""}, + AdHocBookAfterMins: whereHelperint32{field: "\"booking\".\"configuration\".\"ad_hoc_book_after_mins\""}, + AdHocBookForMins: whereHelperint32{field: "\"booking\".\"configuration\".\"ad_hoc_book_for_mins\""}, } // ConfigurationRels is where relationship names are stored. @@ -175,8 +189,8 @@ func (*configurationR) NewStruct() *configurationR { type configurationL struct{} var ( - configurationAllColumns = []string{"id", "start_bookable_hours", "start_bookable_mins", "start_midday_hours", "start_midday_mins", "end_midday_hours", "end_midday_mins", "end_bookable_hours", "end_bookable_mins", "no_show_mins"} - configurationColumnsWithoutDefault = []string{"start_bookable_hours", "start_bookable_mins", "start_midday_hours", "start_midday_mins", "end_midday_hours", "end_midday_mins", "end_bookable_hours", "end_bookable_mins", "no_show_mins"} + configurationAllColumns = []string{"id", "start_bookable_hours", "start_bookable_mins", "start_midday_hours", "start_midday_mins", "end_midday_hours", "end_midday_mins", "end_bookable_hours", "end_bookable_mins", "no_show_mins", "ad_hoc_book_after_mins", "ad_hoc_book_for_mins"} + configurationColumnsWithoutDefault = []string{"start_bookable_hours", "start_bookable_mins", "start_midday_hours", "start_midday_mins", "end_midday_hours", "end_midday_mins", "end_bookable_hours", "end_bookable_mins", "no_show_mins", "ad_hoc_book_after_mins", "ad_hoc_book_for_mins"} configurationColumnsWithDefault = []string{"id"} configurationPrimaryKeyColumns = []string{"id"} configurationGeneratedColumns = []string{} diff --git a/conf/conf.go b/conf/conf.go index d63b4f8..2bc53f9 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -66,20 +66,24 @@ func dbConfigFromApiConfig(apiConfig apiserver.Configuration) appdb.Configuratio EndBookableHours: apiConfig.DayEndHours, EndBookableMins: apiConfig.DayEndMins, NoShowMins: apiConfig.NoShowMins, + AdHocBookAfterMins: apiConfig.AdHocBookAfterMins, + AdHocBookForMins: apiConfig.AdHocBookForMins, } } func apiConfigFromDbConfig(dbConfig appdb.Configuration) apiserver.Configuration { return apiserver.Configuration{ - DayStartHours: dbConfig.StartBookableHours, - DayStartMins: dbConfig.StartBookableMins, - MiddayStartHours: dbConfig.StartMiddayHours, - MiddayStartMins: dbConfig.StartMiddayMins, - MiddayEndHours: dbConfig.EndMiddayHours, - MiddayEndMins: dbConfig.EndMiddayMins, - DayEndHours: dbConfig.EndBookableHours, - DayEndMins: dbConfig.EndBookableMins, - NoShowMins: dbConfig.NoShowMins, + DayStartHours: dbConfig.StartBookableHours, + DayStartMins: dbConfig.StartBookableMins, + MiddayStartHours: dbConfig.StartMiddayHours, + MiddayStartMins: dbConfig.StartMiddayMins, + MiddayEndHours: dbConfig.EndMiddayHours, + MiddayEndMins: dbConfig.EndMiddayMins, + DayEndHours: dbConfig.EndBookableHours, + DayEndMins: dbConfig.EndBookableMins, + NoShowMins: dbConfig.NoShowMins, + AdHocBookAfterMins: dbConfig.AdHocBookAfterMins, + AdHocBookForMins: dbConfig.AdHocBookForMins, } } @@ -406,6 +410,120 @@ func GetNoShowBookings(ctx context.Context) ([]*appdb.Event, error) { return bookings, nil } +type adHocBooking struct { + AssetID int32 `boil:"asset_id"` + Start time.Time `boil:"start_occupied_time"` + End time.Time +} + +func GetRecentlyOccupiedAssets(ctx context.Context) ([]*adHocBooking, error) { + cfg, err := GetConfig(ctx) + if err != nil { + return nil, fmt.Errorf("getting config: %v", err) + } + + bookBuffer := cfg.AdHocBookForMins - cfg.AdHocBookAfterMins + var bookings []*adHocBooking + err = queries. + // The query has to be formatted manually with printf, because sqlboiler does not + // interpret placeholders inside quotes. '%d' should be injection-safe, though. + RawG(fmt.Sprintf(` + WITH StatusChanges AS ( + -- Gather status changes in presence logs, checking for overlapping or upcoming events. + SELECT + pl.asset_id, + pl.occupied, + pl.check_time, + LAG(pl.occupied) OVER (PARTITION BY pl.asset_id ORDER BY pl.check_time) AS prev_occupied, + -- Determine availability of the asset based on event overlaps or events starting soon. + CASE WHEN e.id IS NULL THEN TRUE ELSE FALSE END AS is_available + FROM + booking.presence_logs pl + LEFT JOIN + booking.event_resource er ON pl.asset_id = er.asset_id + LEFT JOIN + booking.event e ON er.event_id = e.id AND ( + (e.start_time <= pl.check_time AND e.end_time > pl.check_time) OR -- Group starts after last past event + (e.start_time BETWEEN pl.check_time AND pl.check_time + INTERVAL '%d MINUTES') -- Upcoming events should not start soon + ) AND e.cancelled_at IS NULL + ), + ConsecutiveOccupied AS ( + -- Calculate groups of consecutive occupied statuses only when the asset is available. + SELECT + asset_id, + occupied, + check_time, + SUM(CASE WHEN occupied = prev_occupied THEN 0 ELSE 1 END) OVER (PARTITION BY asset_id ORDER BY check_time) AS occupied_change_group + FROM + StatusChanges + WHERE + occupied = TRUE AND is_available = TRUE + ), + LatestStatus AS ( + -- Get the latest status of each asset to filter out the ones that are currently unoccupied. + SELECT + asset_id, + occupied, + check_time, + ROW_NUMBER() OVER (PARTITION BY asset_id ORDER BY check_time DESC) AS rn + FROM + booking.presence_logs + ), + FilteredAssets AS ( + -- Consider only those assets whose latest log indicates they are occupied. + SELECT + asset_id + FROM + LatestStatus + WHERE + occupied = TRUE AND rn = 1 + ), + LatestOccupiedGroup AS ( + -- Identify the latest group of consecutive occupation for each asset. + SELECT + c.asset_id, + MAX(c.occupied_change_group) AS latest_group, + MAX(c.check_time) AS end_occupied_time + FROM + ConsecutiveOccupied c + JOIN + FilteredAssets f ON c.asset_id = f.asset_id + GROUP BY + c.asset_id + ) + SELECT + -- Return asset ID and the start and end times of the latest consecutive occupation. + c.asset_id, + MIN(c.check_time) AS start_occupied_time + FROM + ConsecutiveOccupied c + JOIN + LatestOccupiedGroup g ON c.asset_id = g.asset_id AND c.occupied_change_group = g.latest_group + GROUP BY + c.asset_id, g.end_occupied_time + HAVING + -- Ensure the occupation period is long enough and there are no overlapping or upcoming events. + g.end_occupied_time - MIN(c.check_time) >= INTERVAL '%d MINUTES' -- Length of occupation before ad-hoc booking + AND NOT EXISTS ( + SELECT 1 + FROM booking.event e + JOIN booking.event_resource er ON er.event_id = e.id + WHERE er.asset_id = c.asset_id AND ( + e.end_time > MIN(c.check_time) AND + e.start_time < g.end_occupied_time + INTERVAL '%d MINUTES' -- Upcoming events should not start soon + ) AND e.cancelled_at IS NULL + ); + `, bookBuffer, cfg.AdHocBookAfterMins, bookBuffer)). + BindG(ctx, &bookings) + if err != nil { + return nil, fmt.Errorf("querying ad-hoc bookings: %v", err) + } + for i := range bookings { + bookings[i].End = bookings[i].Start.Add(time.Duration(cfg.AdHocBookForMins) * time.Minute) + } + return bookings, nil +} + func CleanupPresenceLogs(ctx context.Context) error { deleted, err := appdb.PresenceLogs( appdb.PresenceLogWhere.CheckTime.LT(time.Now().AddDate(0, 0, -7)), diff --git a/conf/init.sql b/conf/init.sql index 51dc232..9f8bc82 100644 --- a/conf/init.sql +++ b/conf/init.sql @@ -27,14 +27,17 @@ create table if not exists booking.configuration end_bookable_hours int NOT NULL, end_bookable_mins int NOT NULL, no_show_mins int NOT NULL, - CHECK (end_bookable_hours * 60 + end_bookable_mins > start_bookable_hours * 60 + start_bookable_mins) + ad_hoc_book_after_mins int NOT NULL, + ad_hoc_book_for_mins int NOT NULL, + CHECK (end_bookable_hours * 60 + end_bookable_mins > start_bookable_hours * 60 + start_bookable_mins), + CHECK (ad_hoc_book_for_mins >= ad_hoc_book_after_mins) ); -- Default configuration INSERT INTO booking.configuration -(id, start_bookable_hours, start_bookable_mins, start_midday_hours, start_midday_mins, end_midday_hours, end_midday_mins, end_bookable_hours, end_bookable_mins, no_show_mins) +(id, start_bookable_hours, start_bookable_mins, start_midday_hours, start_midday_mins, end_midday_hours, end_midday_mins, end_bookable_hours, end_bookable_mins, no_show_mins, ad_hoc_book_after_mins, ad_hoc_book_for_mins) VALUES -(1, 8, 0, 12, 0, 13, 0, 17, 0, 15) +(1, 8, 0, 12, 0, 13, 0, 17, 0, 15, 15, 30) ON CONFLICT (id) DO NOTHING; create table if not exists booking.event ( @@ -57,7 +60,7 @@ create table if not exists booking.presence_logs ( asset_id INT NOT NULL, event_id BIGINT references booking.event(id), occupied BOOLEAN NOT NULL, - check_time TIMESTAMP NOT NULL DEFAULT NOW() + check_time timestamp with time zone NOT NULL DEFAULT NOW() ); commit; diff --git a/openapi.yaml b/openapi.yaml index 8701309..7ab0a0a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -276,6 +276,12 @@ components: noShowMins: type: integer description: Minutes after which the booking will be automatically cancelled when no one is present. + adHocBookAfterMins: + type: integer + description: Minutes after which the booking will be automatically cancelled when no one is present. + adHocBookForMins: + type: integer + description: Minutes after which the booking will be automatically cancelled when no one is present. Booking: type: object properties: