diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 85c52f44..eaa863a0 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -641,6 +641,15 @@ definitions: user_id: type: string type: object + RequestsOverview: + properties: + pending: + type: integer + unassigned: + type: integer + urgent: + type: integer + type: object RoomRequestsResponse: properties: assigned: @@ -1938,6 +1947,44 @@ paths: summary: Get requests feed tags: - requests + /requests/overview: + post: + consumes: + - application/json + description: Returns counts of urgent (high priority), unassigned, and pending + tasks scoped to the rooms matching the given filters. Accepts the same filter + body as POST /rooms. Does not mutate any data. + parameters: + - description: Hotel ID + in: header + name: X-Hotel-ID + required: true + type: string + - description: Room filters + in: body + name: body + schema: + $ref: '#/definitions/FilterRoomsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/RequestsOverview' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Get requests overview counts + tags: + - requests /rooms: post: consumes: diff --git a/backend/internal/handler/requests.go b/backend/internal/handler/requests.go index 781d850c..efaa6d00 100644 --- a/backend/internal/handler/requests.go +++ b/backend/internal/handler/requests.go @@ -521,6 +521,39 @@ func (r *RequestsHandler) GetRequestsFeed(c *fiber.Ctx) error { return c.JSON(page) } +// GetRequestsOverview godoc +// @Summary Get requests overview counts +// @Description Returns counts of urgent (high priority), unassigned, and pending tasks scoped to the rooms matching the given filters. Accepts the same filter body as POST /rooms. Does not mutate any data. +// @Tags requests +// @Accept json +// @Produce json +// @Param X-Hotel-ID header string true "Hotel ID" +// @Param body body models.FilterRoomsRequest false "Room filters" +// @Success 200 {object} models.RequestsOverview +// @Failure 400 {object} errs.HTTPError +// @Failure 500 {object} errs.HTTPError +// @Security BearerAuth +// @Router /requests/overview [post] +func (r *RequestsHandler) GetRequestsOverview(c *fiber.Ctx) error { + hotelID, err := hotelIDFromHeader(c) + if err != nil { + return err + } + + var body models.FilterRoomsRequest + if err := httpx.BindAndValidate(c, &body); err != nil { + return err + } + + overview, err := r.RequestRepository.GetRequestsOverview(c.Context(), hotelID, &body) + if err != nil { + slog.Error("failed to get requests overview", "err", err, "hotelID", hotelID) + return errs.InternalServerError() + } + + return c.JSON(overview) +} + // parseFeedCursor decodes a universal cursor: "priority_rank|created_at_nano|id". // Returns zero values and nil error for an empty cursor (first page). func parseFeedCursor(cursor string) (id string, createdAt time.Time, priorityRank int, err error) { diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index 7f4ef741..cd8e1310 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -27,6 +27,7 @@ type mockRequestRepository struct { findRequestsByRoomIDAndUserIDFunc func(ctx context.Context, roomID, hotelID, userID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) findUnassignedRequestsByRoomIDFunc func(ctx context.Context, roomID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) findRequestsPaginatedFunc func(ctx context.Context, input *models.RequestsFeedInput, cursorID string, cursorCreatedAt time.Time, cursorPriorityRank int, limit int) ([]*models.GuestRequest, error) + getRequestsOverviewFunc func(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) } func (m *mockRequestRepository) InsertRequest(ctx context.Context, req *models.Request) (*models.Request, error) { @@ -61,6 +62,10 @@ func (m *mockRequestRepository) FindRequestsPaginated(ctx context.Context, input return m.findRequestsPaginatedFunc(ctx, input, cursorID, cursorCreatedAt, cursorPriorityRank, limit) } +func (m *mockRequestRepository) GetRequestsOverview(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + return m.getRequestsOverviewFunc(ctx, hotelID, filters) +} + type mockLLMService struct { runGenerateRequestFunc func(ctx context.Context, input aiflows.GenerateRequestInput) (aiflows.EnrichedGenerateRequestOutput, error) } @@ -1967,3 +1972,176 @@ func TestRequestHandler_GetGenerateRequestStatus(t *testing.T) { assert.Contains(t, string(body), "Extra Towels Request") }) } + +func TestRequestHandler_GetRequestsOverview(t *testing.T) { + t.Parallel() + + const validHotelID = "org_521e8400-e458-41d4-a716-446655440000" + + t.Run("returns 200 with counts on success", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + return &models.RequestsOverview{Urgent: 3, Unassigned: 5, Pending: 7}, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`)) + 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) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), `"urgent":3`) + assert.Contains(t, string(body), `"unassigned":5`) + assert.Contains(t, string(body), `"pending":7`) + }) + + t.Run("returns zeros when no active tasks exist", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + return &models.RequestsOverview{}, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`)) + 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) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), `"urgent":0`) + assert.Contains(t, string(body), `"unassigned":0`) + assert.Contains(t, string(body), `"pending":0`) + }) + + t.Run("passes hotel_id from header to repository", func(t *testing.T) { + t.Parallel() + + var capturedHotelID string + mock := &mockRequestRepository{ + getRequestsOverviewFunc: func(_ context.Context, hotelID string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + capturedHotelID = hotelID + return &models.RequestsOverview{}, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`)) + 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) + assert.Equal(t, validHotelID, capturedHotelID) + }) + + t.Run("passes filters to repository", func(t *testing.T) { + t.Parallel() + + var capturedFilters *models.FilterRoomsRequest + mock := &mockRequestRepository{ + getRequestsOverviewFunc: func(_ context.Context, _ string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + capturedFilters = filters + return &models.RequestsOverview{}, nil + }, + } + + app := fiber.New() + h := NewRequestsHandler(mock, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + body := `{"floors":[1,2],"status":["occupied"],"attributes":["deluxe"],"advanced":["arrivals-today"]}` + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(body)) + 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) + + require.NotNil(t, capturedFilters) + require.NotNil(t, capturedFilters.Floors) + assert.Equal(t, []int{1, 2}, *capturedFilters.Floors) + assert.Equal(t, []string{"occupied"}, capturedFilters.Status) + assert.Equal(t, []string{"deluxe"}, capturedFilters.Attributes) + assert.Equal(t, []string{"arrivals-today"}, capturedFilters.Advanced) + }) + + t.Run("returns 400 when X-Hotel-ID header is missing", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "hotel_id") + }) + + t.Run("returns 400 on invalid JSON body", func(t *testing.T) { + t.Parallel() + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(&mockRequestRepository{}, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{invalid`)) + 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("returns 500 on repository error", func(t *testing.T) { + t.Parallel() + + mock := &mockRequestRepository{ + getRequestsOverviewFunc: func(_ context.Context, _ string, _ *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + return nil, errors.New("db connection failed") + }, + } + + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewRequestsHandler(mock, nil, nil) + app.Post("/requests/overview", h.GetRequestsOverview) + + req := httptest.NewRequest("POST", "/requests/overview", bytes.NewBufferString(`{}`)) + 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, 500, resp.StatusCode) + }) +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index b70f150b..9f38b3bf 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -168,6 +168,12 @@ type RoomRequestsResponse struct { Unassigned []*GuestRequest `json:"unassigned"` } //@name RoomRequestsResponse +type RequestsOverview struct { + Urgent int `json:"urgent"` + Unassigned int `json:"unassigned"` + Pending int `json:"pending"` +} //@name RequestsOverview + type GuestRequest struct { ID string `json:"id"` Name string `json:"name"` diff --git a/backend/internal/repository/requests.go b/backend/internal/repository/requests.go index 2c890c5b..ed53263d 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -352,6 +352,102 @@ func (r *RequestsRepository) FindRequestsPaginated( return scanGuestRequests(rows) } +func (r *RequestsRepository) GetRequestsOverview(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) { + statusFilters := filters.Status + if statusFilters == nil { + statusFilters = []string{} + } + attrFilters := filters.Attributes + if attrFilters == nil { + attrFilters = []string{} + } + advFilters := filters.Advanced + if advFilters == nil { + advFilters = []string{} + } + + // $1 = hotelID, $2 = floors, $3 = status filters, $4 = attribute filters, $5 = advanced filters + var overview models.RequestsOverview + err := r.db.QueryRow(ctx, ` + WITH room_task_info AS ( + SELECT + room_id::uuid AS room_id, + BOOL_OR(user_id IS NULL) AS has_unassigned_tasks + FROM ( + SELECT DISTINCT ON (id) id, room_id, status, user_id + FROM requests + WHERE hotel_id = $1 AND room_id IS NOT NULL + ORDER BY id, request_version DESC + ) latest + WHERE status NOT IN ('completed', 'archived') + GROUP BY room_id + ), + room_enriched AS ( + SELECT + r.id, r.suite_type, r.room_status, r.is_accessible, + CASE WHEN COUNT(gb_active.id) > 0 THEN 'active' ELSE 'inactive' END AS booking_status, + BOOL_OR(gb_arrive.id IS NOT NULL) AS has_arrivals_today, + BOOL_OR(gb_depart.id IS NOT NULL) AS has_departures_today, + COALESCE(BOOL_OR(rti.has_unassigned_tasks), FALSE) AS has_unassigned_tasks + FROM rooms r + LEFT JOIN guest_bookings gb_active ON r.id = gb_active.room_id + AND gb_active.status = 'active' + AND gb_active.hotel_id = $1 + LEFT JOIN guest_bookings gb_arrive ON r.id = gb_arrive.room_id + AND gb_arrive.hotel_id = $1 + AND gb_arrive.arrival_date = CURRENT_DATE + LEFT JOIN guest_bookings gb_depart ON r.id = gb_depart.room_id + AND gb_depart.hotel_id = $1 + AND gb_depart.departure_date = CURRENT_DATE + LEFT JOIN room_task_info rti ON r.id = rti.room_id + WHERE r.hotel_id = $1 + AND ($2::int[] IS NULL OR r.floor = ANY($2)) + GROUP BY r.id, r.suite_type, r.room_status, r.is_accessible + ), + filtered_rooms AS ( + SELECT id FROM room_enriched + WHERE (cardinality($3::text[]) = 0 OR ( + ('occupied' = ANY($3) AND booking_status = 'active') + OR ('vacant' = ANY($3) AND booking_status = 'inactive') + OR ('open-tasks' = ANY($3) AND has_unassigned_tasks) + )) + AND (cardinality($4::text[]) = 0 OR ( + ('standard' = ANY($4) AND LOWER(suite_type) = 'standard') + OR ('deluxe' = ANY($4) AND LOWER(suite_type) = 'deluxe') + OR ('suite' = ANY($4) AND LOWER(suite_type) LIKE '%suite%') + OR ('accessible' = ANY($4) AND is_accessible) + )) + AND (cardinality($5::text[]) = 0 OR ( + ('arrivals-today' = ANY($5) AND has_arrivals_today) + OR ('departures-today' = ANY($5) AND has_departures_today) + )) + ), + latest_requests AS ( + SELECT DISTINCT ON (id) id, room_id, status, priority, user_id + FROM requests + WHERE hotel_id = $1 AND room_id IS NOT NULL + ORDER BY id, request_version DESC + ), + active_requests AS ( + SELECT lr.priority, lr.status, lr.user_id + FROM latest_requests lr + INNER JOIN filtered_rooms fr ON fr.id = lr.room_id::uuid + WHERE lr.status NOT IN ('completed', 'archived') + ) + SELECT + COUNT(*) FILTER (WHERE priority = 'high') AS urgent, + COUNT(*) FILTER (WHERE user_id IS NULL) AS unassigned, + COUNT(*) FILTER (WHERE status = 'pending') AS pending + FROM active_requests + `, hotelID, filters.Floors, statusFilters, attrFilters, advFilters). + Scan(&overview.Urgent, &overview.Unassigned, &overview.Pending) + if err != nil { + return nil, err + } + + return &overview, nil +} + func scanGuestRequests(rows pgx.Rows) ([]*models.GuestRequest, error) { requests := make([]*models.GuestRequest, 0) for rows.Next() { diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 4aca9bc6..0c09f9d8 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -236,6 +236,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo // Request routes api.Post("/requests/feed", reqsHandler.GetRequestsFeed) + api.Post("/requests/overview", reqsHandler.GetRequestsOverview) api.Route("/request", func(r fiber.Router) { r.Post("/", reqsHandler.CreateRequest) r.Post("/generate", reqsHandler.GenerateRequest) diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 4c099a5d..df41fb9f 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -56,6 +56,7 @@ type RequestsRepository interface { FindRequestsByRoomIDAndUserID(ctx context.Context, roomID, hotelID, userID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) FindUnassignedRequestsByRoomIDAndUserID(ctx context.Context, roomID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) FindRequestsPaginated(ctx context.Context, input *models.RequestsFeedInput, cursorID string, cursorCreatedAt time.Time, cursorPriorityRank int, limit int) ([]*models.GuestRequest, error) + GetRequestsOverview(ctx context.Context, hotelID string, filters *models.FilterRoomsRequest) (*models.RequestsOverview, error) } type HotelsRepository interface { diff --git a/clients/shared/src/api/requests.ts b/clients/shared/src/api/requests.ts index 3ecf26d0..707ad841 100644 --- a/clients/shared/src/api/requests.ts +++ b/clients/shared/src/api/requests.ts @@ -81,6 +81,10 @@ export const useAssignRequestToSelf = (_roomId?: string) => { queryKey: REQUESTS_FEED_QUERY_KEY, exact: false, }); + queryClient.invalidateQueries({ + queryKey: REQUESTS_OVERVIEW_QUERY_KEY, + exact: false, + }); queryClient.invalidateQueries({ predicate: (query) => { const key = query.queryKey[0]; @@ -309,6 +313,29 @@ export const useDeleteTask = () => { }); }; +export type RequestsOverview = { + urgent: number; + unassigned: number; + pending: number; +}; + +type RequestsOverviewParams = { + floors?: number[]; + status?: string[]; + attributes?: string[]; + advanced?: string[]; +}; + +export const REQUESTS_OVERVIEW_QUERY_KEY = ["requests-overview"] as const; + +export const useGetRequestsOverview = (params: RequestsOverviewParams) => { + const api = useAPIClient(); + return useQuery({ + queryKey: [...REQUESTS_OVERVIEW_QUERY_KEY, params] as const, + queryFn: () => api.post("/requests/overview", params), + }); +}; + export const useGetRequestById = (requestId: string | null) => { const api = useAPIClient(); return useQuery({ diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index a774cc60..4588e856 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -108,7 +108,9 @@ export type { export { REQUESTS_FEED_QUERY_KEY, + REQUESTS_OVERVIEW_QUERY_KEY, useGetRequestById, + useGetRequestsOverview, useGetRequestsFeed, useInfiniteRequestsByGuest, getGuestRequestsQueryKey, @@ -124,6 +126,7 @@ export type { RequestFeedItem, RequestFeedSort, RequestFeedParams, + RequestsOverview, } from "./api/requests"; export { useCustomInstance } from "./api/orval-mutator"; diff --git a/clients/web/src/components/rooms/OrderByDropdown.tsx b/clients/web/src/components/rooms/OrderByDropdown.tsx index fc6480af..618c2fc9 100644 --- a/clients/web/src/components/rooms/OrderByDropdown.tsx +++ b/clients/web/src/components/rooms/OrderByDropdown.tsx @@ -2,22 +2,26 @@ import { ChevronDown } from "lucide-react"; import { useState } from "react"; import { cn } from "@/lib/utils"; +export type RoomSortOption = "ascending" | "descending" | "urgency"; + type OrderByDropdownProps = { - ascending: boolean; - setAscending: (ascending: boolean) => void; + sortOption: RoomSortOption; + setSortOption: (option: RoomSortOption) => void; }; -const OPTIONS: Array<{ label: string; ascending: boolean }> = [ - { label: "Ascending", ascending: true }, - { label: "Descending", ascending: false }, +const OPTIONS: Array<{ label: string; value: RoomSortOption }> = [ + { label: "Ascending", value: "ascending" }, + { label: "Descending", value: "descending" }, + { label: "Urgency", value: "urgency" }, ]; export function OrderByDropdown({ - ascending, - setAscending, + sortOption, + setSortOption, }: OrderByDropdownProps) { const [open, setOpen] = useState(false); - const currentLabel = ascending ? "Ascending" : "Descending"; + const currentLabel = + OPTIONS.find((o) => o.value === sortOption)?.label ?? "Ascending"; return (
{OPTIONS.map((option) => { - const isSelected = option.ascending === ascending; + const isSelected = option.value === sortOption; return (