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 3376268b..392ae299 100644 --- a/backend/internal/handler/requests.go +++ b/backend/internal/handler/requests.go @@ -532,6 +532,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) +} + // GetRequestActivity godoc // @Summary Get request activity history // @Description Returns an ordered list of activity events derived from the request's version history diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index efe98b58..848ca559 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -23,10 +23,12 @@ type mockRequestRepository struct { updateRequestFunc func(ctx context.Context, id string, update *models.RequestUpdateInput, changedBy *string) (*models.Request, error) findRequestFunc func(ctx context.Context, id string) (*models.Request, error) findRequestsFunc func(ctx context.Context) ([]models.Request, error) + findRequestsByStatusPaginatedFunc func(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) findRequestsByGuestIDFunc func(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) 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) { @@ -45,6 +47,13 @@ func (m *mockRequestRepository) FindRequests(ctx context.Context) ([]models.Requ return m.findRequestsFunc(ctx) } +func (m *mockRequestRepository) FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) { + if m.findRequestsByStatusPaginatedFunc == nil { + return nil, "", nil + } + return m.findRequestsByStatusPaginatedFunc(ctx, cursor, status, hotelID, pageSize) +} + func (m *mockRequestRepository) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) { return m.findRequestsByGuestIDFunc(ctx, guestID, hotelID, cursorID, cursorVersion, limit) } @@ -61,6 +70,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) +} + func (m *mockRequestRepository) FindRequestVersions(ctx context.Context, id string) ([]*models.Request, error) { return nil, nil } @@ -1971,3 +1984,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 98a23d83..89237c25 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -188,6 +188,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 92fd3ff7..bc9840ae 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -355,6 +355,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 (r *RequestsRepository) FindRequestVersions(ctx context.Context, id string) ([]*models.Request, error) { rows, err := r.db.Query(ctx, ` SELECT id, hotel_id, guest_id, reservation_id, name, description, @@ -393,6 +489,51 @@ func (r *RequestsRepository) FindRequestVersions(ctx context.Context, id string) return versions, nil } +func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) { + rows, err := r.db.Query(ctx, ` + WITH latest AS ( + SELECT DISTINCT ON (id) * + FROM requests + WHERE hotel_id = $1 + ORDER BY id, request_version DESC + ) + SELECT * + FROM latest + WHERE status != 'archived' + AND status = $2 + AND ($3::text = '' OR id::text > $3) + ORDER BY id + LIMIT $4 + `, hotelID, status, cursor, pageSize+1) + if err != nil { + return nil, "", err + } + defer rows.Close() + + requests := make([]*models.Request, 0, pageSize) + for rows.Next() { + var req models.Request + if err := rows.Scan( + &req.ID, &req.HotelID, &req.GuestID, + &req.ReservationID, &req.Name, &req.Description, + &req.RoomID, &req.RequestCategory, &req.RequestType, &req.Department, &req.Status, + &req.Priority, &req.EstimatedCompletionTime, &req.ScheduledTime, &req.CompletedAt, &req.Notes, + &req.CreatedAt, &req.UserID, &req.RequestVersion, &req.ChangedBy, + ); err != nil { + return nil, "", err + } + requests = append(requests, &req) + } + if err := rows.Err(); err != nil { + return nil, "", err + } + + if len(requests) == pageSize+1 { + return requests[:pageSize], requests[pageSize-1].ID, nil + } + return requests, "", 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 da2aa987..f62fbba8 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 0f5b96a3..b1b4fb77 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -52,10 +52,12 @@ type RequestsRepository interface { UpdateRequest(ctx context.Context, id string, patch *models.RequestUpdateInput, changedBy *string) (*models.Request, error) FindRequest(ctx context.Context, id string) (*models.Request, error) FindRequests(ctx context.Context) ([]models.Request, error) + FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) 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) FindRequestVersions(ctx context.Context, id string) ([]*models.Request, error) }