Skip to content

Commit 4701446

Browse files
feat: add guests search backend + group_size filter (#218)
* feat: Add group_size to guests_bookings * feat: Add actual SQL for the migration * feat: Add search and and group_size filter * feat: cast to text instead of uuid * feat: Make SQL readable, move validation to handler * feat: Update swagger
1 parent b629591 commit 4701446

File tree

5 files changed

+222
-31
lines changed

5 files changed

+222
-31
lines changed

backend/docs/swagger.yaml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ definitions:
3535
last_name:
3636
example: Doe
3737
type: string
38+
phone_number:
39+
example: "+11234567890"
40+
type: string
41+
primary_email:
42+
example: john@example.com
43+
type: string
3844
profile_picture:
3945
example: https://example.com/john.jpg
4046
type: string
@@ -129,12 +135,18 @@ definitions:
129135
items:
130136
type: integer
131137
type: array
138+
group_size:
139+
items:
140+
type: integer
141+
type: array
132142
hotel_id:
133143
type: string
134144
limit:
135145
maximum: 100
136146
minimum: 1
137147
type: integer
148+
search:
149+
type: string
138150
required:
139151
- hotel_id
140152
type: object
@@ -153,17 +165,22 @@ definitions:
153165
type: string
154166
floor:
155167
type: integer
168+
group_size:
169+
type: integer
156170
id:
157171
type: string
158172
last_name:
159173
type: string
174+
preferred_name:
175+
type: string
160176
room_number:
161177
type: integer
162178
required:
163179
- first_name
164180
- floor
165181
- id
166182
- last_name
183+
- preferred_name
167184
- room_number
168185
type: object
169186
GuestWithStays:
@@ -396,6 +413,12 @@ definitions:
396413
example: America/New_York
397414
type: string
398415
type: object
416+
UpdateUser:
417+
properties:
418+
phone_number:
419+
example: "+11234567890"
420+
type: string
421+
type: object
399422
User:
400423
properties:
401424
created_at:
@@ -419,6 +442,12 @@ definitions:
419442
last_name:
420443
example: Doe
421444
type: string
445+
phone_number:
446+
example: "+11234567890"
447+
type: string
448+
primary_email:
449+
example: john@example.com
450+
type: string
422451
profile_picture:
423452
example: https://example.com/john.jpg
424453
type: string
@@ -1069,6 +1098,52 @@ paths:
10691098
summary: Get user by ID
10701099
tags:
10711100
- users
1101+
put:
1102+
consumes:
1103+
- application/json
1104+
description: Updates fields on a user
1105+
parameters:
1106+
- description: User ID
1107+
in: path
1108+
name: id
1109+
required: true
1110+
type: string
1111+
- description: User update data
1112+
in: body
1113+
name: request
1114+
required: true
1115+
schema:
1116+
$ref: '#/definitions/UpdateUser'
1117+
produces:
1118+
- application/json
1119+
responses:
1120+
"200":
1121+
description: OK
1122+
schema:
1123+
$ref: '#/definitions/User'
1124+
"400":
1125+
description: Bad Request
1126+
schema:
1127+
additionalProperties:
1128+
type: string
1129+
type: object
1130+
"404":
1131+
description: Not Found
1132+
schema:
1133+
additionalProperties:
1134+
type: string
1135+
type: object
1136+
"500":
1137+
description: Internal Server Error
1138+
schema:
1139+
additionalProperties:
1140+
type: string
1141+
type: object
1142+
security:
1143+
- BearerAuth: []
1144+
summary: Updates a user
1145+
tags:
1146+
- users
10721147
schemes:
10731148
- http
10741149
- https

backend/internal/handler/guests.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package handler
33
import (
44
"errors"
55
"log/slog"
6+
"strings"
67

78
"github.com/generate/selfserve/internal/errs"
89
"github.com/generate/selfserve/internal/httpx"
@@ -176,8 +177,28 @@ func (h *GuestsHandler) GetGuests(c *fiber.Ctx) error {
176177
return err
177178
}
178179

180+
if len(filters.Floors) == 0 {
181+
filters.Floors = nil
182+
}
183+
if len(filters.GroupSize) == 0 {
184+
filters.GroupSize = nil
185+
}
186+
187+
if filters.Cursor != "" {
188+
parts := strings.SplitN(filters.Cursor, "|", 2)
189+
if len(parts) != 2 {
190+
return errs.BadRequest("invalid cursor")
191+
}
192+
if _, err := uuid.Parse(parts[1]); err != nil {
193+
return errs.BadRequest("invalid cursor")
194+
}
195+
filters.CursorName = parts[0]
196+
filters.CursorID = parts[1]
197+
}
198+
179199
guests, err := h.GuestsRepository.FindGuestsWithActiveBooking(c.Context(), &filters)
180200
if err != nil {
201+
slog.Error("failed to get guests", "error", err)
181202
return errs.InternalServerError()
182203
}
183204

backend/internal/handler/guests_test.go

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -643,12 +643,13 @@ func TestGuestsHandler_GetGuests(t *testing.T) {
643643
t.Run("returns 200 with cursor and limit", func(t *testing.T) {
644644
t.Parallel()
645645

646-
cursor := "530e8400-e458-41d4-a716-446655440000"
647-
nextCursor := "530e8400-e458-41d4-a716-446655440001"
646+
cursor := "John Doe|530e8400-e458-41d4-a716-446655440000"
647+
nextCursor := "Jane Smith|530e8400-e458-41d4-a716-446655440001"
648648

649649
mock := &mockGuestsRepository{
650650
findGuestsFunc: func(ctx context.Context, f *models.GuestFilters) (*models.GuestPage, error) {
651-
assert.Equal(t, cursor, f.Cursor)
651+
assert.Equal(t, "John Doe", f.CursorName)
652+
assert.Equal(t, "530e8400-e458-41d4-a716-446655440000", f.CursorID)
652653
assert.Equal(t, 10, f.Limit)
653654
return &models.GuestPage{
654655
Data: []*models.GuestWithBooking{},
@@ -724,6 +725,69 @@ func TestGuestsHandler_GetGuests(t *testing.T) {
724725
assert.Equal(t, 400, resp.StatusCode)
725726
})
726727

728+
t.Run("returns 400 on cursor with pipe but invalid UUID", func(t *testing.T) {
729+
t.Parallel()
730+
731+
mock := &mockGuestsRepository{}
732+
app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
733+
h := NewGuestsHandler(mock)
734+
app.Post("/guests/search", h.GetGuests)
735+
736+
req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"cursor":"John Doe|not-a-uuid"}`))
737+
req.Header.Set("Content-Type", "application/json")
738+
req.Header.Set("X-Hotel-ID", validHotelID)
739+
740+
resp, err := app.Test(req)
741+
require.NoError(t, err)
742+
assert.Equal(t, 400, resp.StatusCode)
743+
})
744+
745+
t.Run("passes search filter to repository", func(t *testing.T) {
746+
t.Parallel()
747+
748+
mock := &mockGuestsRepository{
749+
findGuestsFunc: func(ctx context.Context, f *models.GuestFilters) (*models.GuestPage, error) {
750+
assert.Equal(t, "john", f.Search)
751+
return &models.GuestPage{Data: []*models.GuestWithBooking{}, NextCursor: nil}, nil
752+
},
753+
}
754+
755+
app := fiber.New()
756+
h := NewGuestsHandler(mock)
757+
app.Post("/guests/search", h.GetGuests)
758+
759+
req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"search":"john"}`))
760+
req.Header.Set("Content-Type", "application/json")
761+
req.Header.Set("X-Hotel-ID", validHotelID)
762+
763+
resp, err := app.Test(req)
764+
require.NoError(t, err)
765+
assert.Equal(t, 200, resp.StatusCode)
766+
})
767+
768+
t.Run("passes group_size filter to repository", func(t *testing.T) {
769+
t.Parallel()
770+
771+
mock := &mockGuestsRepository{
772+
findGuestsFunc: func(ctx context.Context, f *models.GuestFilters) (*models.GuestPage, error) {
773+
assert.Equal(t, []int{2, 3}, f.GroupSize)
774+
return &models.GuestPage{Data: []*models.GuestWithBooking{}, NextCursor: nil}, nil
775+
},
776+
}
777+
778+
app := fiber.New()
779+
h := NewGuestsHandler(mock)
780+
app.Post("/guests/search", h.GetGuests)
781+
782+
req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"group_size":[2,3]}`))
783+
req.Header.Set("Content-Type", "application/json")
784+
req.Header.Set("X-Hotel-ID", validHotelID)
785+
786+
resp, err := app.Test(req)
787+
require.NoError(t, err)
788+
assert.Equal(t, 200, resp.StatusCode)
789+
})
790+
727791
t.Run("returns 400 on invalid JSON", func(t *testing.T) {
728792
t.Parallel()
729793

backend/internal/models/guests.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ type Guest struct {
2424
} //@name Guest
2525

2626
type GuestFilters struct {
27-
HotelID string `json:"hotel_id" validate:"required,uuid"`
28-
Floors []int `json:"floors"`
29-
Cursor string `json:"cursor" validate:"omitempty,uuid"`
30-
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
27+
HotelID string `json:"hotel_id" validate:"required,uuid"`
28+
Floors []int `json:"floors"`
29+
GroupSize []int `json:"group_size"`
30+
Search string `json:"search"`
31+
Cursor string `json:"cursor"`
32+
CursorName string `json:"-"`
33+
CursorID string `json:"-"`
34+
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
3135
} // @name GuestFilters
3236

3337
type GuestPage struct {
@@ -36,11 +40,13 @@ type GuestPage struct {
3640
} // @name GuestPage
3741

3842
type GuestWithBooking struct {
39-
ID string `json:"id" validate:"required"`
40-
FirstName string `json:"first_name" validate:"required"`
41-
LastName string `json:"last_name" validate:"required"`
42-
Floor int `json:"floor" validate:"required"`
43-
RoomNumber int `json:"room_number" validate:"required"`
43+
ID string `json:"id"`
44+
FirstName string `json:"first_name"`
45+
LastName string `json:"last_name"`
46+
PreferredName string `json:"preferred_name"`
47+
Floor int `json:"floor"`
48+
RoomNumber int `json:"room_number"`
49+
GroupSize *int `json:"group_size"`
4450
} // @name GuestWithBooking
4551

4652
type GuestWithStays struct {

backend/internal/repository/guests.go

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri
8686

8787
rows, err := r.db.Query(ctx, `
8888
SELECT guests.id, guests.first_name, guests.last_name, guests.phone, guests.email,
89-
guests.preferences, guests.notes, guest_bookings.arrival_date, guest_bookings.departure_date,
89+
guests.preferences, guests.notes, guest_bookings.arrival_date, guest_bookings.departure_date,
9090
rooms.room_number, guest_bookings.status
9191
FROM public.guests
9292
LEFT JOIN guest_bookings ON guests.id = guest_bookings.guest_id
@@ -192,22 +192,45 @@ func (r *GuestsRepository) UpdateGuest(ctx context.Context, id string, update *m
192192
}
193193

194194
func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filters *models.GuestFilters) (*models.GuestPage, error) {
195-
floors := filters.Floors
196-
if floors == nil {
197-
floors = []int{}
198-
}
195+
floorsFilter := filters.Floors
196+
groupSizesFilter := filters.GroupSize
197+
199198
rows, err := r.db.Query(ctx, `
200-
SELECT
201-
guests.id, guests.first_name, guests.last_name, rooms.room_number, rooms.floor
202-
FROM guests
203-
JOIN guest_bookings ON guests.id = guest_bookings.guest_id
204-
AND guest_bookings.status = 'active'
205-
JOIN rooms ON rooms.id = guest_bookings.room_id
206-
WHERE guest_bookings.hotel_id = $1
207-
AND ($2::int[] = '{}' OR rooms.floor = ANY($2))
208-
AND ($3 = '' OR guests.id > $3::uuid)
209-
ORDER BY guests.id
210-
LIMIT $4`, filters.HotelID, floors, filters.Cursor, filters.Limit+1)
199+
WITH guest_data AS (
200+
SELECT
201+
g.id,
202+
g.first_name,
203+
g.last_name,
204+
CONCAT_WS(' ', g.first_name, g.last_name) AS full_name,
205+
COALESCE(g.preferences, g.first_name) AS preferred_name,
206+
r.floor,
207+
r.room_number,
208+
gb.group_size,
209+
gb.hotel_id,
210+
gb.status
211+
FROM guest_bookings gb
212+
JOIN guests g ON g.id = gb.guest_id
213+
JOIN rooms r ON r.id = gb.room_id
214+
)
215+
SELECT id, first_name, last_name, preferred_name, floor, room_number, group_size
216+
FROM guest_data
217+
WHERE hotel_id = $1
218+
AND status = 'active'
219+
AND ($2::int[] IS NULL OR floor = ANY($2))
220+
AND ($3::int[] IS NULL OR group_size = ANY($3))
221+
AND (
222+
$4::text = ''
223+
OR full_name ILIKE '%' || $4 || '%'
224+
OR room_number::text ILIKE '%' || $4 || '%'
225+
)
226+
AND (
227+
$5::text = ''
228+
OR (full_name, id::text) > ($5::text, $6::text)
229+
)
230+
ORDER BY full_name ASC, id ASC
231+
LIMIT $7`,
232+
filters.HotelID, floorsFilter, groupSizesFilter, filters.Search, filters.CursorName, filters.CursorID, filters.Limit+1,
233+
)
211234
if err != nil {
212235
return nil, err
213236
}
@@ -216,7 +239,7 @@ func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filt
216239
var guests []*models.GuestWithBooking
217240
for rows.Next() {
218241
var g models.GuestWithBooking
219-
err := rows.Scan(&g.ID, &g.FirstName, &g.LastName, &g.RoomNumber, &g.Floor)
242+
err := rows.Scan(&g.ID, &g.FirstName, &g.LastName, &g.PreferredName, &g.Floor, &g.RoomNumber, &g.GroupSize)
220243
if err != nil {
221244
return nil, err
222245
}
@@ -230,11 +253,13 @@ func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filt
230253
var nextCursor *string
231254
if len(guests) == filters.Limit+1 {
232255
guests = guests[:filters.Limit]
233-
nextCursor = &guests[filters.Limit-1].ID
256+
last := guests[filters.Limit-1]
257+
encoded := last.FirstName + " " + last.LastName + "|" + last.ID
258+
nextCursor = &encoded
234259
}
260+
235261
return &models.GuestPage{
236262
Data: guests,
237263
NextCursor: nextCursor,
238264
}, nil
239-
240265
}

0 commit comments

Comments
 (0)