Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ definitions:
last_name:
example: Doe
type: string
phone_number:
example: "+11234567890"
type: string
primary_email:
example: john@example.com
type: string
profile_picture:
example: https://example.com/john.jpg
type: string
Expand Down Expand Up @@ -129,12 +135,18 @@ definitions:
items:
type: integer
type: array
group_size:
items:
type: integer
type: array
hotel_id:
type: string
limit:
maximum: 100
minimum: 1
type: integer
search:
type: string
required:
- hotel_id
type: object
Expand All @@ -153,17 +165,22 @@ definitions:
type: string
floor:
type: integer
group_size:
type: integer
id:
type: string
last_name:
type: string
preferred_name:
type: string
room_number:
type: integer
required:
- first_name
- floor
- id
- last_name
- preferred_name
- room_number
type: object
GuestWithStays:
Expand Down Expand Up @@ -396,6 +413,12 @@ definitions:
example: America/New_York
type: string
type: object
UpdateUser:
properties:
phone_number:
example: "+11234567890"
type: string
type: object
User:
properties:
created_at:
Expand All @@ -419,6 +442,12 @@ definitions:
last_name:
example: Doe
type: string
phone_number:
example: "+11234567890"
type: string
primary_email:
example: john@example.com
type: string
profile_picture:
example: https://example.com/john.jpg
type: string
Expand Down Expand Up @@ -1069,6 +1098,52 @@ paths:
summary: Get user by ID
tags:
- users
put:
consumes:
- application/json
description: Updates fields on a user
parameters:
- description: User ID
in: path
name: id
required: true
type: string
- description: User update data
in: body
name: request
required: true
schema:
$ref: '#/definitions/UpdateUser'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/User'
"400":
description: Bad Request
schema:
additionalProperties:
type: string
type: object
"404":
description: Not Found
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal Server Error
schema:
additionalProperties:
type: string
type: object
security:
- BearerAuth: []
summary: Updates a user
tags:
- users
schemes:
- http
- https
Expand Down
21 changes: 21 additions & 0 deletions backend/internal/handler/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"errors"
"log/slog"
"strings"

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

if len(filters.Floors) == 0 {
filters.Floors = nil
}
if len(filters.GroupSize) == 0 {
filters.GroupSize = nil
}

if filters.Cursor != "" {
parts := strings.SplitN(filters.Cursor, "|", 2)
if len(parts) != 2 {
return errs.BadRequest("invalid cursor")
}
if _, err := uuid.Parse(parts[1]); err != nil {
return errs.BadRequest("invalid cursor")
}
filters.CursorName = parts[0]
filters.CursorID = parts[1]
}

guests, err := h.GuestsRepository.FindGuestsWithActiveBooking(c.Context(), &filters)
if err != nil {
slog.Error("failed to get guests", "error", err)
return errs.InternalServerError()
}

Expand Down
70 changes: 67 additions & 3 deletions backend/internal/handler/guests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -643,12 +643,13 @@ func TestGuestsHandler_GetGuests(t *testing.T) {
t.Run("returns 200 with cursor and limit", func(t *testing.T) {
t.Parallel()

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

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

t.Run("returns 400 on cursor with pipe but invalid UUID", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{}
app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler})
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.GetGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"cursor":"John Doe|not-a-uuid"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 400, resp.StatusCode)
})

t.Run("passes search filter to repository", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestsFunc: func(ctx context.Context, f *models.GuestFilters) (*models.GuestPage, error) {
assert.Equal(t, "john", f.Search)
return &models.GuestPage{Data: []*models.GuestWithBooking{}, NextCursor: nil}, nil
},
}

app := fiber.New()
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.GetGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"search":"john"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
})

t.Run("passes group_size filter to repository", func(t *testing.T) {
t.Parallel()

mock := &mockGuestsRepository{
findGuestsFunc: func(ctx context.Context, f *models.GuestFilters) (*models.GuestPage, error) {
assert.Equal(t, []int{2, 3}, f.GroupSize)
return &models.GuestPage{Data: []*models.GuestWithBooking{}, NextCursor: nil}, nil
},
}

app := fiber.New()
h := NewGuestsHandler(mock)
app.Post("/guests/search", h.GetGuests)

req := httptest.NewRequest("POST", "/guests/search", bytes.NewBufferString(`{"group_size":[2,3]}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Hotel-ID", validHotelID)

resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, 200, resp.StatusCode)
})

t.Run("returns 400 on invalid JSON", func(t *testing.T) {
t.Parallel()

Expand Down
24 changes: 15 additions & 9 deletions backend/internal/models/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ type Guest struct {
} //@name Guest

type GuestFilters struct {
HotelID string `json:"hotel_id" validate:"required,uuid"`
Floors []int `json:"floors"`
Cursor string `json:"cursor" validate:"omitempty,uuid"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
HotelID string `json:"hotel_id" validate:"required,uuid"`
Floors []int `json:"floors"`
GroupSize []int `json:"group_size"`
Search string `json:"search"`
Cursor string `json:"cursor"`
CursorName string `json:"-"`
CursorID string `json:"-"`
Limit int `json:"limit" validate:"omitempty,min=1,max=100"`
} // @name GuestFilters

type GuestPage struct {
Expand All @@ -36,11 +40,13 @@ type GuestPage struct {
} // @name GuestPage

type GuestWithBooking struct {
ID string `json:"id" validate:"required"`
FirstName string `json:"first_name" validate:"required"`
LastName string `json:"last_name" validate:"required"`
Floor int `json:"floor" validate:"required"`
RoomNumber int `json:"room_number" validate:"required"`
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
PreferredName string `json:"preferred_name"`
Floor int `json:"floor"`
RoomNumber int `json:"room_number"`
GroupSize *int `json:"group_size"`
} // @name GuestWithBooking

type GuestWithStays struct {
Expand Down
63 changes: 44 additions & 19 deletions backend/internal/repository/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri

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

func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filters *models.GuestFilters) (*models.GuestPage, error) {
floors := filters.Floors
if floors == nil {
floors = []int{}
}
floorsFilter := filters.Floors
groupSizesFilter := filters.GroupSize

rows, err := r.db.Query(ctx, `
SELECT
guests.id, guests.first_name, guests.last_name, rooms.room_number, rooms.floor
FROM guests
JOIN guest_bookings ON guests.id = guest_bookings.guest_id
AND guest_bookings.status = 'active'
JOIN rooms ON rooms.id = guest_bookings.room_id
WHERE guest_bookings.hotel_id = $1
AND ($2::int[] = '{}' OR rooms.floor = ANY($2))
AND ($3 = '' OR guests.id > $3::uuid)
ORDER BY guests.id
LIMIT $4`, filters.HotelID, floors, filters.Cursor, filters.Limit+1)
WITH guest_data AS (
SELECT
g.id,
g.first_name,
g.last_name,
CONCAT_WS(' ', g.first_name, g.last_name) AS full_name,
COALESCE(g.preferences, g.first_name) AS preferred_name,
r.floor,
r.room_number,
gb.group_size,
gb.hotel_id,
gb.status
FROM guest_bookings gb
JOIN guests g ON g.id = gb.guest_id
JOIN rooms r ON r.id = gb.room_id
)
SELECT id, first_name, last_name, preferred_name, floor, room_number, group_size
FROM guest_data
WHERE hotel_id = $1
AND status = 'active'
AND ($2::int[] IS NULL OR floor = ANY($2))
AND ($3::int[] IS NULL OR group_size = ANY($3))
AND (
$4::text = ''
OR full_name ILIKE '%' || $4 || '%'
OR room_number::text ILIKE '%' || $4 || '%'
)
AND (
$5::text = ''
OR (full_name, id::text) > ($5::text, $6::text)
)
ORDER BY full_name ASC, id ASC
LIMIT $7`,
filters.HotelID, floorsFilter, groupSizesFilter, filters.Search, filters.CursorName, filters.CursorID, filters.Limit+1,
)
if err != nil {
return nil, err
}
Expand All @@ -216,7 +239,7 @@ func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filt
var guests []*models.GuestWithBooking
for rows.Next() {
var g models.GuestWithBooking
err := rows.Scan(&g.ID, &g.FirstName, &g.LastName, &g.RoomNumber, &g.Floor)
err := rows.Scan(&g.ID, &g.FirstName, &g.LastName, &g.PreferredName, &g.Floor, &g.RoomNumber, &g.GroupSize)
if err != nil {
return nil, err
}
Expand All @@ -230,11 +253,13 @@ func (r *GuestsRepository) FindGuestsWithActiveBooking(ctx context.Context, filt
var nextCursor *string
if len(guests) == filters.Limit+1 {
guests = guests[:filters.Limit]
nextCursor = &guests[filters.Limit-1].ID
last := guests[filters.Limit-1]
encoded := last.FirstName + " " + last.LastName + "|" + last.ID
nextCursor = &encoded
}

return &models.GuestPage{
Data: guests,
NextCursor: nextCursor,
}, nil

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.guest_bookings
ADD COLUMN group_size INT;
Loading