diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 9ddb335c..45bb6595 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -303,6 +303,9 @@ definitions: request_type: example: recurring type: string + request_version: + example: "2024-01-02T00:00:00Z" + type: string reservation_id: example: 521e8400-e458-41d4-a716-498655440000 type: string @@ -315,9 +318,6 @@ definitions: status: example: assigned type: string - updated_at: - example: "2024-01-02T00:00:00Z" - type: string user_id: example: 521ee400-e458-41d4-a716-446655440000 type: string @@ -426,6 +426,34 @@ definitions: type: integer message: {} type: object + github_com_generate_selfserve_internal_models.CreateGuest: + properties: + first_name: + example: Jane + type: string + last_name: + example: Doe + type: string + profile_picture: + example: https://example.com/john.jpg + type: string + timezone: + example: America/New_York + type: string + type: object + github_com_generate_selfserve_internal_models.UpdateGuest: + properties: + first_name: + example: Jane + type: string + last_name: + example: Doe + type: string + profile_picture: + example: https://example.com/john.jpg + type: string + timezone: + example: America/New_York github_com_generate_selfserve_internal_models.BookingStatus: enum: - active @@ -475,7 +503,11 @@ paths: name: body required: true schema: +<<<<<<< requests-versioning-2 + $ref: '#/definitions/github_com_generate_selfserve_internal_models.CreateGuest' +======= $ref: '#/definitions/GuestFilters' +>>>>>>> main produces: - application/json responses: @@ -554,7 +586,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/UpdateGuest' + $ref: '#/definitions/github_com_generate_selfserve_internal_models.UpdateGuest' produces: - application/json responses: diff --git a/backend/internal/handler/requests.go b/backend/internal/handler/requests.go index bc1fce1b..e5030106 100644 --- a/backend/internal/handler/requests.go +++ b/backend/internal/handler/requests.go @@ -8,6 +8,7 @@ import ( "github.com/generate/selfserve/internal/aiflows" "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/httpx" "github.com/generate/selfserve/internal/models" storage "github.com/generate/selfserve/internal/service/storage/postgres" "github.com/gofiber/fiber/v2" @@ -40,17 +41,12 @@ func NewRequestsHandler(repo storage.RequestsRepository, generateRequestService // @Security BearerAuth // @Router /request [post] func (r *RequestsHandler) CreateRequest(c *fiber.Ctx) error { - var incoming models.MakeRequest - if err := c.BodyParser(&incoming); err != nil { - return errs.InvalidJSON() - } - req := models.Request{MakeRequest: incoming} - - if err := validateCreateRequest(&req); err != nil { + var requestBody models.MakeRequest + if err := httpx.BindAndValidate(c, &requestBody); err != nil { return err } - res, err := r.RequestRepository.InsertRequest(c.Context(), &req) + res, err := r.RequestRepository.InsertRequest(c.Context(), &models.Request{MakeRequest: requestBody}) if err != nil { return errs.InternalServerError() } @@ -58,40 +54,23 @@ func (r *RequestsHandler) CreateRequest(c *fiber.Ctx) error { return c.JSON(res) } -func validateCreateRequest(req *models.Request) error { - errors := make(map[string]string) - - if !validUUID(req.HotelID) { - errors["hotel_id"] = "invalid uuid" +func (r *RequestsHandler) UpdateRequest(c *fiber.Ctx) error { + id := c.Params("id") + if !validUUID(id) { + return errs.BadRequest("request id is not a valid UUID") } - if req.GuestID != nil && !validUUID(*req.GuestID) { - errors["guest_id"] = "invalid uuid" - } - if req.UserID != nil && !validUUID(*req.UserID) { - errors["user_id"] = "invalid uuid" - } - if req.Name == "" { - errors["name"] = "must not be an empty string" - } - if req.RequestType == "" { - errors["request_type"] = "must not be an empty string" - } - if req.Status == "" { - errors["status"] = "must not be an empty string" - } - if req.Priority == "" { - errors["priority"] = "must not be an empty string" + var requestBody models.MakeRequest + if err := httpx.BindAndValidate(c, &requestBody); err != nil { + return err } - if len(errors) > 0 { - var parts []string - for field, violation := range errors { - parts = append(parts, field+": "+violation) - } - return errs.BadRequest(strings.Join(parts, ", ")) + res, err := r.RequestRepository.InsertRequest(c.Context(), &models.Request{ID: id, MakeRequest: requestBody}) + if err != nil { + return errs.InternalServerError() } - return nil + + return c.JSON(res) } func (r *RequestsHandler) GetRequest(c *fiber.Ctx) error { @@ -121,14 +100,14 @@ func (r *RequestsHandler) GetRequests(c *fiber.Ctx) error { return c.JSON(dev) } -func validateGenerateRequest(incoming *models.GenerateRequestInput) error { +func validateGenerateRequest(input *models.GenerateRequestInput) error { errors := make(map[string]string) - if !validUUID(incoming.HotelID) { + if !validUUID(input.HotelID) { errors["hotel_id"] = "invalid uuid" } - if incoming.RawText == "" { + if input.RawText == "" { errors["raw_text"] = "must not be an empty string" } @@ -207,17 +186,17 @@ func (r *RequestsHandler) GetRequestByCursor(c *fiber.Ctx) error { // @Security BearerAuth // @Router /request/generate [post] func (r *RequestsHandler) GenerateRequest(c *fiber.Ctx) error { - var incoming models.GenerateRequestInput - if err := c.BodyParser(&incoming); err != nil { + var input models.GenerateRequestInput + if err := c.BodyParser(&input); err != nil { return errs.InvalidJSON() } - if err := validateGenerateRequest(&incoming); err != nil { + if err := validateGenerateRequest(&input); err != nil { return err } parsed, err := r.GenerateRequestService.RunGenerateRequest(c.Context(), aiflows.GenerateRequestInput{ - RawText: incoming.RawText, + RawText: input.RawText, }) if err != nil { slog.Error("genkit failed to generate a request", "error", err) @@ -225,7 +204,7 @@ func (r *RequestsHandler) GenerateRequest(c *fiber.Ctx) error { } req := models.Request{MakeRequest: models.MakeRequest{ - HotelID: incoming.HotelID, + HotelID: input.HotelID, GuestID: parsed.GuestID, UserID: parsed.UserID, ReservationID: parsed.ReservationID, @@ -243,10 +222,6 @@ func (r *RequestsHandler) GenerateRequest(c *fiber.Ctx) error { Notes: parsed.Notes, }} - if err := validateCreateRequest(&req); err != nil { - return err - } - res, err := r.RequestRepository.InsertRequest(c.Context(), &req) if err != nil { return errs.InternalServerError() diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index d6b52fba..98ee5eba 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -57,9 +57,9 @@ func TestRequestHandler_GetRequest(t *testing.T) { mock := &mockRequestRepository{ findRequestFunc: func(ctx context.Context, name string) (*models.Request, error) { return &models.Request{ - ID: "530e8400-e458-41d4-a716-446655440000", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440000", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "room cleaning", @@ -175,9 +175,9 @@ func TestRequestHandler_GetRequests(t *testing.T) { findRequestsFunc: func(ctx context.Context) ([]models.Request, error) { requests := []models.Request{ { - ID: "530e8400-e458-41d4-a716-446655440000", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440000", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "room cleaning", @@ -187,9 +187,9 @@ func TestRequestHandler_GetRequests(t *testing.T) { }, }, { - ID: "530e8400-e458-41d4-a716-446655440001", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440001", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "towel replacement", @@ -199,9 +199,9 @@ func TestRequestHandler_GetRequests(t *testing.T) { }, }, { - ID: "530e8400-e458-41d4-a716-446655440002", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440002", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "maintenance repair", @@ -211,9 +211,9 @@ func TestRequestHandler_GetRequests(t *testing.T) { }, }, { - ID: "530e8400-e458-41d4-a716-446655440003", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440003", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "extra pillows", @@ -223,9 +223,9 @@ func TestRequestHandler_GetRequests(t *testing.T) { }, }, { - ID: "530e8400-e458-41d4-a716-446655440004", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440004", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "minibar refill", @@ -516,7 +516,6 @@ func TestRequestHandler_Generate_Request(t *testing.T) { makeRequestFunc: func(ctx context.Context, req *models.Request) (*models.Request, error) { req.ID = "generated-uuid" req.CreatedAt = time.Now() - req.UpdatedAt = time.Now() return req, nil }, } @@ -851,9 +850,9 @@ func TestRequestHandler_GetRequestByCursor(t *testing.T) { findRequestsByCursorFunc: func(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) { return []*models.Request{ { - ID: "530e8400-e458-41d4-a716-446655440001", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440001", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "room cleaning", @@ -863,9 +862,9 @@ func TestRequestHandler_GetRequestByCursor(t *testing.T) { }, }, { - ID: "530e8400-e458-41d4-a716-446655440002", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "530e8400-e458-41d4-a716-446655440002", + CreatedAt: time.Now(), + RequestVersion: time.Now(), MakeRequest: models.MakeRequest{ HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "towel request", diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index ccefbf31..e47b38f0 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -33,8 +33,8 @@ type MakeRequest struct { RequestCategory *string `json:"request_category" example:"Cleaning"` RequestType string `json:"request_type" validate:"notblank" example:"recurring"` Department *string `json:"department" example:"maintenance"` - Status string `json:"status" validate:"notblank" example:"assigned"` - Priority string `json:"priority" validate:"notblank" example:"urgent"` + Status string `json:"status" validate:"oneof=pending assigned completed" example:"assigned"` + Priority string `json:"priority" validate:"oneof=low medium normal high urgent" example:"urgent"` EstimatedCompletionTime *int `json:"estimated_completion_time" example:"30"` ScheduledTime *time.Time `json:"scheduled_time" example:"2024-01-01T00:00:00Z"` CompletedAt *time.Time `json:"completed_at" example:"2024-01-01T00:30:00Z"` @@ -47,8 +47,8 @@ type GenerateRequestInput struct { } //@name GenerateRequestInput type Request struct { - ID string `json:"id" example:"530e8400-e458-41d4-a716-446655440000"` - CreatedAt time.Time `json:"created_at" example:"2024-01-02T00:00:00Z"` - UpdatedAt time.Time `json:"updated_at" example:"2024-01-02T00:00:00Z"` + ID string `json:"id" example:"530e8400-e458-41d4-a716-446655440000"` + CreatedAt time.Time `json:"created_at" example:"2024-01-02T00:00:00Z"` + RequestVersion time.Time `json:"request_version" example:"2024-01-02T00:00:00Z"` MakeRequest } //@name Request diff --git a/backend/internal/repository/requests.go b/backend/internal/repository/requests.go index 45c12b65..3d568a82 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -19,16 +19,26 @@ func NewRequestsRepo(db *pgxpool.Pool) *RequestsRepository { } func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Request) (*models.Request, error) { - err := r.db.QueryRow(ctx, `INSERT INTO requests ( - hotel_id, guest_id, user_id, reservation_id, name, description, - room_id, request_category, request_type, department, status, - priority, estimated_completion_time, scheduled_time, notes - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING id, created_at, updated_at - `, req.HotelID, req.GuestID, req.UserID, req.ReservationID, req.Name, + if req.ID == "" { + return nil, errors.New("request ID must be provided by the caller") + } + + err := r.db.QueryRow(ctx, ` + INSERT INTO requests ( + id, hotel_id, guest_id, user_id, reservation_id, name, description, + room_id, request_category, request_type, department, status, + priority, estimated_completion_time, scheduled_time, notes, + request_version, created_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + NOW(), + COALESCE((SELECT MIN(created_at) FROM requests WHERE id = $1), NOW()) + ) + RETURNING id, created_at, request_version + `, req.ID, req.HotelID, req.GuestID, req.UserID, req.ReservationID, req.Name, req.Description, req.RoomID, req.RequestCategory, req.RequestType, req.Department, req.Status, req.Priority, req.EstimatedCompletionTime, - req.ScheduledTime, req.Notes).Scan(&req.ID, &req.CreatedAt, &req.UpdatedAt) + req.ScheduledTime, req.Notes).Scan(&req.ID, &req.CreatedAt, &req.RequestVersion) if err != nil { return nil, err @@ -43,15 +53,17 @@ func (r *RequestsRepository) FindRequest(ctx context.Context, id string) (*model SELECT * FROM requests WHERE id = $1 + ORDER BY request_version DESC + LIMIT 1 `, id) var request models.Request err := row.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.UserID, &request.ReservationID, &request.Name, &request.Description, + &request.ReservationID, &request.Name, &request.Description, &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UpdatedAt) + &request.CreatedAt, &request.UserID, &request.RequestVersion) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -82,10 +94,10 @@ func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, for rows.Next() { var request models.Request err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.UserID, &request.ReservationID, &request.Name, &request.Description, + &request.ReservationID, &request.Name, &request.Description, &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UpdatedAt) + &request.CreatedAt, &request.UserID, &request.RequestVersion) if err != nil { return nil, "", err } @@ -114,10 +126,10 @@ func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request for rows.Next() { var request models.Request err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &request.UserID, &request.ReservationID, &request.Name, &request.Description, + &request.ReservationID, &request.Name, &request.Description, &request.RoomID, &request.RequestCategory, &request.RequestType, &request.Department, &request.Status, &request.Priority, &request.EstimatedCompletionTime, &request.ScheduledTime, &request.CompletedAt, &request.Notes, - &request.CreatedAt, &request.UpdatedAt) + &request.CreatedAt, &request.UserID, &request.RequestVersion) if err != nil { return nil, err } diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 97cf1286..7d611348 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -163,6 +163,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo api.Route("/request", func(r fiber.Router) { r.Post("/", reqsHandler.CreateRequest) r.Post("/generate", reqsHandler.GenerateRequest) + r.Put("/:id", reqsHandler.UpdateRequest) r.Get("/:id", reqsHandler.GetRequest) r.Get("/cursor/:cursor", reqsHandler.GetRequestByCursor) }) diff --git a/backend/supabase/migrations/20260217134812_add-request-version-column.sql b/backend/supabase/migrations/20260217134812_add-request-version-column.sql index 30fdaad3..bad2830e 100644 --- a/backend/supabase/migrations/20260217134812_add-request-version-column.sql +++ b/backend/supabase/migrations/20260217134812_add-request-version-column.sql @@ -10,4 +10,4 @@ SET request_version = created_at; ALTER TABLE public.requests ALTER COLUMN request_version DROP DEFAULT; -ALTER TABLE public.requests DROP COLUMN updated_at \ No newline at end of file +ALTER TABLE public.requests DROP COLUMN updated_at diff --git a/backend/supabase/migrations/20260317000000_requests-composite-pk.sql b/backend/supabase/migrations/20260317000000_requests-composite-pk.sql index dafed940..8af0d112 100644 --- a/backend/supabase/migrations/20260317000000_requests-composite-pk.sql +++ b/backend/supabase/migrations/20260317000000_requests-composite-pk.sql @@ -1,3 +1,3 @@ ALTER TABLE public.requests DROP CONSTRAINT requests_pkey; -ALTER TABLE public.requests ADD CONSTRAINT requests_pkey PRIMARY KEY (id, request_version); \ No newline at end of file +ALTER TABLE public.requests ADD CONSTRAINT requests_pkey PRIMARY KEY (id, request_version); diff --git a/clients/web/src/tests/generated-types.test.ts b/clients/web/src/tests/generated-types.test.ts index 11a9a247..bde339ee 100644 --- a/clients/web/src/tests/generated-types.test.ts +++ b/clients/web/src/tests/generated-types.test.ts @@ -38,7 +38,7 @@ describe("Generated Types Integration", () => { status: "pending", priority: "high", created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", + request_version: "2024-01-01T00:00:00Z", }; expect(request.id).toBe("456");