Skip to content
This repository has been archived by the owner on May 22, 2024. It is now read-only.

Commit

Permalink
Implement ad-hoc booking
Browse files Browse the repository at this point in the history
  • Loading branch information
zdevaty committed May 10, 2024
1 parent 819a331 commit 7e50dbc
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 15 deletions.
6 changes: 6 additions & 0 deletions apiserver/model_configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand All @@ -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).
Expand Down Expand Up @@ -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()
Expand Down
18 changes: 16 additions & 2 deletions appdb/configuration.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

136 changes: 127 additions & 9 deletions conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)),
Expand Down
11 changes: 7 additions & 4 deletions conf/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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;
6 changes: 6 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit 7e50dbc

Please sign in to comment.