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
271 changes: 159 additions & 112 deletions backend/docs/swagger.yaml

Large diffs are not rendered by default.

49 changes: 13 additions & 36 deletions backend/internal/handler/bookings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@ package handler

import (
"context"
"strconv"
"strings"

"github.com/generate/selfserve/internal/errs"
"github.com/generate/selfserve/internal/models"
"github.com/gofiber/fiber/v2"
)

type GuestBookingsRepository interface {
FindBookingByFloor(ctx context.Context, floors []int) ([]*models.GuestBooking, error)
FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error)
}

type GuestBookingHandler struct {
Expand All @@ -22,44 +19,24 @@ func NewGuestBookingsHandler(repo GuestBookingsRepository) *GuestBookingHandler
return &GuestBookingHandler{repo: repo}
}

// GetBookingsByFloor godoc
// @Summary Get guest Bookings By Floor
// @Description Retrieves multiple guest bookings whose booked rooms are in the provided floors array
// GetGroupSizeOptions godoc
// @Summary Get available group size options
// @Description Retrieves all distinct group sizes across guest bookings
// @Tags guest-bookings
// @Produce json
// @Param floors query string true "Comma-separated floor numbers"
// @Success 200 {object} []models.GuestBooking
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /guest_bookings/floor [get]
func (h *GuestBookingHandler) GetBookingsByFloor(c *fiber.Ctx) error {
floors, err := getQueryFloors(c.Query("floors"))
if err != nil {
return err
// @Success 200 {object} []int
// @Failure 500 {object} map[string]string
// @Router /guest_bookings/group_sizes [get]
func (h *GuestBookingHandler) GetGroupSizeOptions(c *fiber.Ctx) error {
hotelID := c.Get("X-Hotel-ID")
if hotelID == "" {
return errs.BadRequest("X-Hotel-ID header is required")
}

bookings, err := h.repo.FindBookingByFloor(c.Context(), floors)

sizes, err := h.repo.FindGroupSizeOptions(c.Context(), hotelID)
if err != nil {
return errs.InternalServerError()
}

return c.JSON(bookings)
}

func getQueryFloors(rawFloors string) ([]int, error) {
if rawFloors == "" {
return nil, errs.BadRequest("Floors must be provided")
}

parts := strings.Split(rawFloors, ",")
floors := make([]int, 0, len(parts))
for _, p := range parts {
floor, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return nil, errs.BadRequest("Floors must be an array of integers")
}
floors = append(floors, floor)
}
return floors, nil
return c.JSON(sizes)
}
2 changes: 1 addition & 1 deletion backend/internal/handler/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func (h *GuestsHandler) GetGuest(c *fiber.Ctx) error {
// @Failure 404 {object} errs.HTTPError "Guest not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Security BearerAuth
// @Router /api/v1/guests/stays/{id} [get]
// @Router /guests/stays/{id} [get]
func (h *GuestsHandler) GetGuestWithStays(c *fiber.Ctx) error {
id := c.Params("id")
if !validUUID(id) {
Expand Down
12 changes: 6 additions & 6 deletions backend/internal/models/guests.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ type GuestPage struct {
} // @name GuestPage

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

type GuestWithStays struct {
Expand Down
27 changes: 27 additions & 0 deletions backend/internal/repository/guest-bookings.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package repository

import (
"context"

"github.com/jackc/pgx/v5/pgxpool"
)

Expand All @@ -11,3 +13,28 @@ type GuestBookingsRepository struct {
func NewGuestBookingsRepository(db *pgxpool.Pool) *GuestBookingsRepository {
return &GuestBookingsRepository{db: db}
}

func (r *GuestBookingsRepository) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) {
rows, err := r.db.Query(ctx, `
SELECT DISTINCT group_size
FROM guest_bookings
WHERE hotel_id = $1
AND group_size IS NOT NULL
ORDER BY group_size ASC
`, hotelID)
if err != nil {
return nil, err
}
defer rows.Close()

var sizes []int
for rows.Next() {
var size int
if err := rows.Scan(&size); err != nil {
return nil, err
}
sizes = append(sizes, size)
}

return sizes, rows.Err()
}
6 changes: 6 additions & 0 deletions backend/internal/service/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo
hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB))
s3Handler := handler.NewS3Handler(s3Store)
roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB))
guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB))

clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg)
if err != nil {
Expand Down Expand Up @@ -203,6 +204,11 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo
r.Get("/floors", roomsHandler.GetFloors)
})

// guest booking routes
api.Route("/guest_bookings", func(r fiber.Router) {
r.Get("/group_sizes", guestBookingsHandler.GetGroupSizeOptions)
})

// s3 routes
api.Route("/s3", func(r fiber.Router) {
r.Get("/presigned-url/:key", s3Handler.GeneratePresignedURL)
Expand Down
92 changes: 73 additions & 19 deletions clients/mobile/app/(tabs)/guests/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,81 @@
import { useLocalSearchParams } from "expo-router";
import { Text } from "react-native";
import GuestProfile from "@/components/ui/guest-profile";
import { useGetApiV1GuestsStaysId } from "@shared/api/generated/endpoints/guests/guests";
import { useState } from "react";
import {
View,
Text,
Pressable,
ScrollView,
ActivityIndicator,
} from "react-native";
import { Info, ChevronRight } from "lucide-react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useGetGuestsStaysId } from "@shared/api/generated/endpoints/guests/guests";
import { GuestHeader, Tab } from "@/components/ui/guest-header";
import { GuestProfileTab } from "@/components/ui/guest-profile";
import { GuestActivityTab } from "@/components/ui/guest-activity";
import { Colors } from "@/constants/theme";

export default function ProfileScreen() {
export default function GuestProfileScreen() {
const { id } = useLocalSearchParams();
const { data, isLoading } = useGetGuestsStaysId(id as string);
const [activeTab, setActiveTab] = useState<Tab>("profile");

const query = useGetApiV1GuestsStaysId(id as string);
if (isLoading)
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);

if (!query || !query.data) {
return <Text>Guest not found</Text>;
}
if (!data)
return (
<View className="flex-1 items-center justify-center">
<Text>Guest not found</Text>
</View>
);

return (
<GuestProfile
firstName={query.data.first_name}
lastName={query.data.last_name}
phone={query.data.phone}
email={query.data.email}
notes={query.data.notes}
preferences={query.data.preferences}
currentStays={query.data.current_stays}
previousStays={query.data.past_stays}
/>
<View className="flex-1 bg-white">
<GuestHeader
name={`${data.first_name} ${data.last_name}`}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
<WaitingRequestsBanner name={data.first_name} />
<ScrollView className="flex-1">
{activeTab === "profile" ? (
<GuestProfileTab
firstName={data.first_name}
lastName={data.last_name}
phone={data.phone}
email={data.email}
notes={data.notes}
preferences={data.preferences}
specificAssistance={[]}
/>
) : (
<GuestActivityTab
currentStays={data.current_stays ?? []}
pastStays={data.past_stays ?? []}
onViewAllBookings={() =>
router.push(`/guests/booking-history?id=${id}`)
}
/>
)}
</ScrollView>
</View>
);
}

function WaitingRequestsBanner({ name }: { name: string }) {
return (
<Pressable className="flex-row items-center justify-between px-[4vw] py-[1.5vh] bg-primary">
<View className="flex-row items-center gap-[2vw]">
<Info size={16} color={Colors.light.background} />
<Text className="text-white text-[3.5vw]">
{name} is waiting on requests
</Text>
</View>
<ChevronRight size={16} color={Colors.light.background} />
</Pressable>
);
}
103 changes: 103 additions & 0 deletions clients/mobile/app/(tabs)/guests/booking-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { View, Text, Pressable, SectionList } from "react-native";
import { ChevronLeft } from "lucide-react-native";
import { router, useLocalSearchParams } from "expo-router";
import { Colors } from "@/constants/theme";
import { useGetGuestsStaysId } from "@shared/api/generated/endpoints/guests/guests";
import type { Stay } from "@shared/api/generated/models";

export default function BookingHistoryScreen() {
const { id } = useLocalSearchParams();
const { data, isLoading } = useGetGuestsStaysId(id as string);

const currentStays = data?.current_stays ?? [];
const pastStays = data?.past_stays ?? [];

const grouped = pastStays.reduce<Record<string, Stay[]>>((acc, stay) => {
const year = new Date(stay.arrival_date).getFullYear().toString();
if (!acc[year]) acc[year] = [];
acc[year].push(stay);
return acc;
}, {});

const pastSections = Object.entries(grouped)
.sort(([a], [b]) => Number(b) - Number(a))
.map(([year, data]) => ({ title: year, data, active: false }));

const sections = [
...(currentStays.length > 0
? [{ title: "Active", data: currentStays, active: true }]
: []),
...pastSections,
];

return (
<View className="flex-1 bg-white">
<View className="flex-row items-center px-[4vw] py-[3vh] border-b border-stroke-subtle">
<Pressable onPress={() => router.back()}>
<ChevronLeft size={24} color={Colors.light.text} />
</Pressable>
<Text className="flex-1 text-center text-[5vw] font-semibold text-black">
All Bookings
</Text>
<View className="w-[6vw]" />
</View>

{isLoading ? (
<Text className="text-center mt-[4vh] text-[3.5vw] text-shadow-strong">
Loading...
</Text>
) : (
<SectionList
sections={sections}
keyExtractor={(_, i) => i.toString()}
contentContainerStyle={{ padding: 16, gap: 8 }}
renderSectionHeader={({ section }) => (
<Text className="text-[3.5vw] font-semibold text-shadow-strong mt-[2vh] mb-[1vh]">
{section.title}
</Text>
)}
renderItem={({ item, section }) => (
<BookingCard stay={item} isActive={section.active} />
)}
/>
)}
</View>
);
}

function BookingCard({ stay, isActive }: { stay: Stay; isActive: boolean }) {
const fmt = (d: string | Date) =>
new Date(d).toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});

return (
<View
className={`rounded-xl p-[4vw] mb-[2vw] border ${
isActive
? "bg-success-accent border-success-stroke"
: "bg-white border-stroke-subtle"
}`}
>
<View className="flex-row items-center justify-between mb-[1vh]">
<Text
className={`text-[4vw] font-semibold ${isActive ? "text-primary" : "text-black"}`}
>
Room {stay.room_number}
</Text>
<Text
className={`text-[3.5vw] ${isActive ? "text-primary" : "text-shadow-strong"}`}
>
{stay.status}
</Text>
</View>
<Text
className={`text-[3vw] ${isActive ? "text-primary" : "text-shadow-strong"}`}
>
{fmt(stay.arrival_date)} - {fmt(stay.departure_date)}
</Text>
</View>
);
}
Loading
Loading