diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..958c7bf82 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index d8dad6405..2e212d9cc 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -374,6 +374,9 @@ definitions: hotel_id: example: org_521e8400-e458-41d4-a716-446655440000 type: string + location_display: + example: North wing, 12B + type: string name: example: room cleaning type: string @@ -471,6 +474,9 @@ definitions: id: example: 530e8400-e458-41d4-a716-446655440000 type: string + location_display: + example: North wing, 12B + type: string name: example: room cleaning type: string @@ -674,6 +680,25 @@ definitions: name: type: string type: object + github_com_generate_selfserve_internal_models.CreateTaskBody: + properties: + assign_to_me: + type: boolean + department: + type: string + description: + type: string + name: + type: string + priority: + enum: + - low + - medium + - high + type: string + required: + - name + type: object github_com_generate_selfserve_internal_models.FloorSortOrder: enum: - ascending @@ -698,6 +723,18 @@ definitions: x-enum-varnames: - TypeTaskAssigned - TypeHighPriorityTask + github_com_generate_selfserve_internal_models.PatchTaskBody: + properties: + status: + enum: + - pending + - assigned + - in progress + - completed + type: string + required: + - status + type: object github_com_generate_selfserve_internal_models.RequestSortOrder: enum: - high_to_low @@ -708,6 +745,27 @@ definitions: - RequestSortHighToLow - RequestSortLowToHigh - RequestSortUrgent + github_com_generate_selfserve_internal_models.Task: + properties: + department: + type: string + description: + type: string + due_time: + type: string + id: + type: string + is_assigned: + type: boolean + location: + type: string + priority: + type: string + status: + type: string + title: + type: string + type: object github_com_generate_selfserve_internal_models.UpdateDepartment: properties: name: @@ -725,6 +783,18 @@ definitions: description: nil when no more pages type: string type: object + github_com_generate_selfserve_internal_utils.CursorPage-github_com_generate_selfserve_internal_models_Task: + properties: + has_more: + type: boolean + items: + items: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.Task' + type: array + next_cursor: + description: nil when no more pages + type: string + type: object internal_handler.AddEmployeeDepartmentBody: properties: department_id: @@ -1797,6 +1867,179 @@ paths: summary: Get presigned URL for profile picture upload tags: - s3 + /tasks: + get: + description: Cursor-paginated tasks for my work or the unassigned pool + parameters: + - description: my or unassigned + in: query + name: tab + required: true + type: string + - description: Page size (default 20) + in: query + name: limit + type: integer + - description: Opaque cursor + in: query + name: cursor + type: string + - description: Filter by status + in: query + name: status + type: string + - description: Filter by department (case-insensitive) + in: query + name: department + type: string + - description: Filter by priority + in: query + name: priority + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_utils.CursorPage-github_com_generate_selfserve_internal_models_Task' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: List staff tasks + tags: + - tasks + post: + consumes: + - application/json + description: Creates a lightweight adhoc request for the authenticated user's + hotel + parameters: + - description: Task + in: body + name: body + required: true + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.CreateTaskBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Create adhoc staff task + tags: + - tasks + /tasks/{id}: + patch: + consumes: + - application/json + parameters: + - description: Request id + in: path + name: id + required: true + type: string + - description: Patch + in: body + name: body + required: true + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.PatchTaskBody' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "400": + description: Bad Request + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Update task status + tags: + - tasks + /tasks/{id}/claim: + post: + parameters: + - description: Request id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Claim unassigned task + tags: + - tasks + /tasks/{id}/drop: + post: + parameters: + - description: Request id + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/Request' + "404": + description: Not Found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "409": + description: Conflict + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + security: + - BearerAuth: [] + summary: Drop claimed task back to pool + tags: + - tasks /users: post: consumes: diff --git a/backend/internal/errs/http.go b/backend/internal/errs/http.go index 5467d31e2..9a2febfcc 100644 --- a/backend/internal/errs/http.go +++ b/backend/internal/errs/http.go @@ -43,6 +43,11 @@ func Conflict(title string, withKey string, withValue any) HTTPError { return NewHTTPError(http.StatusConflict, fmt.Errorf("conflict: %s with %s='%s' already exists", title, withKey, withValue)) } +// TaskStateHTTPConflict is returned when claim/drop or similar task preconditions fail. +func TaskStateHTTPConflict() HTTPError { + return NewHTTPError(http.StatusConflict, errors.New("task state conflict")) +} + func InvalidRequestData(errors map[string]string) HTTPError { return HTTPError{ Code: http.StatusUnprocessableEntity, diff --git a/backend/internal/errs/repository.go b/backend/internal/errs/repository.go index b9f5d1990..82c11f9dd 100644 --- a/backend/internal/errs/repository.go +++ b/backend/internal/errs/repository.go @@ -4,7 +4,12 @@ import "errors" // Repository layer errors (errors thrown at the DB level) var ( - ErrNotFoundInDB = errors.New("not found in DB") - ErrAlreadyExistsInDB = errors.New("already exists in DB") + ErrNotFoundInDB = errors.New("not found in DB") + ErrAlreadyExistsInDB = errors.New("already exists in DB") + + ErrTaskStateConflict = errors.New("task state conflict") + ErrRequestUnknownHotel = errors.New("unknown hotel for request") + ErrRequestUnknownAssignee = errors.New("unknown assignee for request") + ErrRequestInvalidUserID = errors.New("invalid user id for request") ErrDefaultDepartmentInsertDB = errors.New("failed to insert default departments") ) diff --git a/backend/internal/handler/auth_context.go b/backend/internal/handler/auth_context.go new file mode 100644 index 000000000..a0a724d3a --- /dev/null +++ b/backend/internal/handler/auth_context.go @@ -0,0 +1,38 @@ +package handler + +import ( + "context" + "errors" + "strings" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/gofiber/fiber/v2" +) + +type authUserLookup interface { + FindUser(ctx context.Context, id string) (*models.User, error) +} + +// userIDAndHotelFromAuth resolves the Clerk subject from JWT locals and loads the user's hotel_id. +func userIDAndHotelFromAuth(c *fiber.Ctx, users authUserLookup) (clerkID, hotelID string, err error) { + raw := c.Locals("userId") + clerkID, _ = raw.(string) + if strings.TrimSpace(clerkID) == "" { + return "", "", errs.Unauthorized() + } + + u, ferr := users.FindUser(c.Context(), clerkID) + if ferr != nil { + if errors.Is(ferr, errs.ErrNotFoundInDB) { + return "", "", errs.BadRequest("user is not registered; complete sign-up first") + } + return "", "", errs.InternalServerError() + } + + if strings.TrimSpace(u.HotelID) == "" { + return "", "", errs.BadRequest("user has no hotel assigned") + } + + return clerkID, u.HotelID, nil +} diff --git a/backend/internal/handler/hotels_test.go b/backend/internal/handler/hotels_test.go index b2f04a598..bb4228156 100644 --- a/backend/internal/handler/hotels_test.go +++ b/backend/internal/handler/hotels_test.go @@ -103,14 +103,13 @@ func TestHotelHandler_GetHotelByID(t *testing.T) { func TestHotelsHandler_CreateHotel(t *testing.T) { t.Parallel() - floors := 10 validBody := `{ "id": "org_2abc123", "name": "The Grand Budapest Hotel", "floors": 10 }` - newMock := func(returnFloors *int) *mockHotelsRepository { + newMock := func() *mockHotelsRepository { return &mockHotelsRepository{ insertHotelFunc: func(ctx context.Context, hotel *models.CreateHotelRequest) (*models.Hotel, error) { return &models.Hotel{ diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index 7039baba8..89d792f4c 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -25,6 +25,10 @@ type mockRequestRepository struct { findRequestsByGuestIDFunc func(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) findMyRequestsByRoomIDFunc 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) + findTasksFunc func(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) + updateTaskStatusFunc func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error + claimTaskFunc func(ctx context.Context, hotelID, requestID, clerkUserID string) error + dropTaskFunc func(ctx context.Context, hotelID, requestID, clerkUserID string) error } func (m *mockRequestRepository) InsertRequest(ctx context.Context, req *models.Request) (*models.Request, error) { @@ -63,6 +67,33 @@ func (m *mockLLMService) RunGenerateRequest(ctx context.Context, input aiflows.G return m.runGenerateRequestFunc(ctx, input) } +func (m *mockRequestRepository) FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + if m.findTasksFunc == nil { + return []models.Task{}, nil + } + return m.findTasksFunc(ctx, hotelID, clerkUserID, filter, cursorRank, cursorDeptKey, cursorCreatedAt, cursorID, hasCursor) +} + +func (m *mockRequestRepository) UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + if m.updateTaskStatusFunc == nil { + return nil + } + return m.updateTaskStatusFunc(ctx, hotelID, requestID, clerkUserID, newStatus) +} + +func (m *mockRequestRepository) ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + if m.claimTaskFunc == nil { + return nil + } + return m.claimTaskFunc(ctx, hotelID, requestID, clerkUserID) +} + +func (m *mockRequestRepository) DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + if m.dropTaskFunc == nil { + return nil + } + return m.dropTaskFunc(ctx, hotelID, requestID, clerkUserID) +} func TestRequestHandler_GetRequest(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/tasks.go b/backend/internal/handler/tasks.go new file mode 100644 index 000000000..2ece79678 --- /dev/null +++ b/backend/internal/handler/tasks.go @@ -0,0 +1,281 @@ +package handler + +import ( + "errors" + "strings" + "time" + + "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/generate/selfserve/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" +) + +type TasksHandler struct { + repo storage.RequestsRepository + users authUserLookup +} + +func NewTasksHandler(repo storage.RequestsRepository, users authUserLookup) *TasksHandler { + return &TasksHandler{repo: repo, users: users} +} + +// GetTasks godoc +// @Summary List staff tasks +// @Description Cursor-paginated tasks for my work or the unassigned pool +// @Tags tasks +// @Produce json +// @Param tab query string true "my or unassigned" +// @Param limit query int false "Page size (default 20)" +// @Param cursor query string false "Opaque cursor" +// @Param status query string false "Filter by status" +// @Param department query string false "Filter by department (case-insensitive)" +// @Param priority query string false "Filter by priority" +// @Success 200 {object} utils.CursorPage[models.Task] +// @Failure 400 {object} errs.HTTPError +// @Failure 401 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks [get] +func (h *TasksHandler) GetTasks(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + var q models.TaskFilter + if err := c.QueryParser(&q); err != nil { + return errs.BadRequest("invalid query parameters") + } + if err := httpx.Validate(&q); err != nil { + return err + } + if !q.Tab.IsValid() { + return errs.BadRequest("tab must be my or unassigned") + } + + hasCursor := strings.TrimSpace(q.Cursor) != "" + var ( + cPR int + cDK string + cCA time.Time + cID string + ) + if hasCursor { + cPR, cDK, cCA, cID, err = utils.DecodeTaskCursor(q.Cursor, q.Tab) + if err != nil { + return errs.BadRequest(err.Error()) + } + } + + items, err := h.repo.FindTasks(c.Context(), hotelID, clerkID, q, cPR, cDK, cCA, cID, hasCursor) + if err != nil { + return errs.InternalServerError() + } + + page := utils.BuildCursorPage(items, utils.ResolveLimit(q.Limit), func(t models.Task) string { return t.Cursor }) + return c.JSON(page) +} + +// CreateTask godoc +// @Summary Create adhoc staff task +// @Description Creates a lightweight adhoc request for the authenticated user's hotel +// @Tags tasks +// @Accept json +// @Produce json +// @Param body body models.CreateTaskBody true "Task" +// @Success 200 {object} models.Request +// @Failure 400 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks [post] +func (h *TasksHandler) CreateTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + var body models.CreateTaskBody + if err := httpx.BindAndValidate(c, &body); err != nil { + return err + } + + priority := strings.TrimSpace(body.Priority) + if priority == "" { + priority = string(models.PriorityMedium) + } + + status := string(models.StatusPending) + var userID *string + if body.AssignToMe { + u := clerkID + userID = &u + status = string(models.StatusAssigned) + } + + desc := strings.TrimSpace(body.Description) + var descPtr *string + if desc != "" { + descPtr = &desc + } + + dept := strings.TrimSpace(body.Department) + var deptPtr *string + if dept != "" { + deptPtr = &dept + } + + emptyNotes := "" + req := &models.Request{ + ID: uuid.New().String(), + MakeRequest: models.MakeRequest{ + HotelID: hotelID, + Name: strings.TrimSpace(body.Name), + Description: descPtr, + RequestType: "adhoc", + Status: status, + Priority: priority, + Department: deptPtr, + UserID: userID, + Notes: &emptyNotes, + }, + } + + res, err := h.repo.InsertRequest(c.Context(), req) + if err != nil { + var pe *pgconn.PgError + if errors.As(err, &pe) && pe.Code == "23503" { + return errs.BadRequest("invalid hotel or user reference") + } + return errs.InternalServerError() + } + + return c.JSON(res) +} + +// PatchTask godoc +// @Summary Update task status +// @Tags tasks +// @Accept json +// @Produce json +// @Param id path string true "Request id" +// @Param body body models.PatchTaskBody true "Patch" +// @Success 200 {object} models.Request +// @Failure 400 {object} errs.HTTPError +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id} [patch] +func (h *TasksHandler) PatchTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + var body models.PatchTaskBody + if err := httpx.BindAndValidate(c, &body); err != nil { + return err + } + + err = h.repo.UpdateTaskStatus(c.Context(), hotelID, id, clerkID, body.Status) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} + +// ClaimTask godoc +// @Summary Claim unassigned task +// @Tags tasks +// @Produce json +// @Param id path string true "Request id" +// @Success 200 {object} models.Request +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id}/claim [post] +func (h *TasksHandler) ClaimTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + err = h.repo.ClaimTask(c.Context(), hotelID, id, clerkID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} + +// DropTask godoc +// @Summary Drop claimed task back to pool +// @Tags tasks +// @Produce json +// @Param id path string true "Request id" +// @Success 200 {object} models.Request +// @Failure 404 {object} errs.HTTPError +// @Failure 409 {object} errs.HTTPError +// @Security BearerAuth +// @Router /tasks/{id}/drop [post] +func (h *TasksHandler) DropTask(c *fiber.Ctx) error { + clerkID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + id := c.Params("id") + if _, err := uuid.Parse(id); err != nil { + return errs.BadRequest("task id is not a valid UUID") + } + + err = h.repo.DropTask(c.Context(), hotelID, id, clerkID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + if errors.Is(err, errs.ErrTaskStateConflict) { + return errs.TaskStateHTTPConflict() + } + return errs.InternalServerError() + } + + updated, err := h.repo.FindRequest(c.Context(), id) + if err != nil { + return errs.InternalServerError() + } + return c.JSON(updated) +} diff --git a/backend/internal/handler/tasks_test.go b/backend/internal/handler/tasks_test.go new file mode 100644 index 000000000..9584a2535 --- /dev/null +++ b/backend/internal/handler/tasks_test.go @@ -0,0 +1,280 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/generate/selfserve/internal/utils" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockAuthUsers struct { + findUser func(ctx context.Context, id string) (*models.User, error) +} + +func (m *mockAuthUsers) FindUser(ctx context.Context, id string) (*models.User, error) { + if m.findUser == nil { + h := "521e8400-e458-41d4-a716-446655440000" + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: h}}, nil + } + return m.findUser(ctx, id) +} + +func testTasksApp(t *testing.T, repo *mockRequestRepository, users *mockAuthUsers, localsUserID string) *fiber.App { + t.Helper() + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + if localsUserID != "" { + app.Use(func(c *fiber.Ctx) error { + c.Locals("userId", localsUserID) + return c.Next() + }) + } + h := NewTasksHandler(repo, users) + app.Get("/tasks", h.GetTasks) + app.Post("/tasks", h.CreateTask) + app.Patch("/tasks/:id", h.PatchTask) + app.Post("/tasks/:id/claim", h.ClaimTask) + app.Post("/tasks/:id/drop", h.DropTask) + return app +} + +func TestTasksHandler_GetTasks(t *testing.T) { + t.Parallel() + + t.Run("returns 401 when userId missing", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "") + req := httptest.NewRequest("GET", "/tasks?tab=my", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) + }) + + t.Run("returns 400 when user has no hotel", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{ + findUser: func(ctx context.Context, id string) (*models.User, error) { + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: ""}}, nil + }, + }, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 400 when tab missing", func(t *testing.T) { + t.Parallel() + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 400 when cursor tab mismatches", func(t *testing.T) { + t.Parallel() + cur, err := utils.EncodeTaskCursor(models.TaskTabUnassigned, 2, "hk", time.Now().UTC(), "00000000-0000-0000-0000-000000000099") + require.NoError(t, err) + app := testTasksApp(t, &mockRequestRepository{}, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my&cursor="+cur, nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 200 with items", func(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC) + dept := "Housekeeping" + task := models.Task{ + ID: "00000000-0000-0000-0000-0000000000aa", Title: "Clean", Priority: "high", + Department: &dept, Location: "Room 101", Status: "assigned", IsAssigned: true, + } + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, utils.PriorityRank("high"), utils.DepartmentKey(&dept), ts, task.ID) + require.NoError(t, err) + task.Cursor = cur + + repo := &mockRequestRepository{ + findTasksFunc: func(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + assert.Equal(t, "521e8400-e458-41d4-a716-446655440000", hotelID) + assert.Equal(t, "user_clerk_1", clerkUserID) + assert.Equal(t, models.TaskTabMy, filter.Tab) + return []models.Task{task}, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("GET", "/tasks?tab=my&limit=20", nil) + 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), `"items"`) + assert.Contains(t, string(body), "Clean") + assert.Contains(t, string(body), `"has_more":false`) + }) +} + +func TestTasksHandler_CreateTask(t *testing.T) { + t.Parallel() + + t.Run("returns 200", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + makeRequestFunc: func(ctx context.Context, req *models.Request) (*models.Request, error) { + assert.Equal(t, "adhoc", req.RequestType) + assert.Equal(t, string(models.StatusPending), req.Status) + req.CreatedAt = time.Now() + req.RequestVersion = time.Now() + return req, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks", strings.NewReader(`{"name":"Quick task"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + +func TestTasksHandler_PatchTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on state conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) + + t.Run("returns 404 when not found", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return errs.ErrNotFoundInDB + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) + + t.Run("returns 200", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + return nil + }, + findRequestFunc: func(ctx context.Context, id string) (*models.Request, error) { + return &models.Request{ + ID: id, CreatedAt: time.Now(), RequestVersion: time.Now(), + MakeRequest: models.MakeRequest{ + HotelID: "521e8400-e458-41d4-a716-446655440000", Name: "x", RequestType: "adhoc", + Status: "completed", Priority: "low", Notes: ptrStr(""), + }, + }, nil + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("PATCH", "/tasks/"+rid, strings.NewReader(`{"status":"completed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + +func ptrStr(s string) *string { return &s } + +func TestTasksHandler_ClaimTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + claimTaskFunc: func(ctx context.Context, hotelID, requestID, clerkUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks/"+rid+"/claim", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) +} + +func TestTasksHandler_DropTask(t *testing.T) { + t.Parallel() + rid := "530e8400-e458-41d4-a716-446655440000" + + t.Run("returns 409 on conflict", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + dropTaskFunc: func(ctx context.Context, hotelID, requestID, clerkUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := testTasksApp(t, repo, &mockAuthUsers{}, "user_clerk_1") + req := httptest.NewRequest("POST", "/tasks/"+rid+"/drop", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) +} + +func TestTaskCursorRoundTrip(t *testing.T) { + t.Parallel() + ts := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, 3, "dept", ts, "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + pr, dk, ca, id, err := utils.DecodeTaskCursor(cur, models.TaskTabMy) + require.NoError(t, err) + assert.Equal(t, 3, pr) + assert.Equal(t, "dept", dk) + assert.True(t, ca.Equal(ts)) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", id) +} + +func TestTaskCursorWrongTab(t *testing.T) { + t.Parallel() + cur, err := utils.EncodeTaskCursor(models.TaskTabMy, 1, "", time.Now(), "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + _, _, _, _, err = utils.DecodeTaskCursor(cur, models.TaskTabUnassigned) + assert.Error(t, err) +} + +// Ensure CursorPage JSON shape for mobile. +func TestCursorPageTasksJSON(t *testing.T) { + t.Parallel() + p := utils.CursorPage[models.Task]{ + Items: []models.Task{{ID: "a", Title: "t", Location: "x", Status: "pending", IsAssigned: false}}, + HasMore: false, + } + b, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(b), `"next_cursor":null`) +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 735064a64..8446d4a3f 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -46,6 +46,7 @@ type MakeRequest struct { Name string `json:"name" validate:"notblank" example:"room cleaning"` Description *string `json:"description" example:"clean 504"` RoomID *string `json:"room_id" example:"521e8422-e458-41d4-a716-446655440000"` + LocationDisplay *string `json:"location_display,omitempty" example:"North wing, 12B"` RequestCategory *string `json:"request_category" example:"Cleaning"` RequestType string `json:"request_type" validate:"notblank" example:"recurring"` Department *string `json:"department" example:"maintenance"` diff --git a/backend/internal/models/tasks.go b/backend/internal/models/tasks.go new file mode 100644 index 000000000..9834d3164 --- /dev/null +++ b/backend/internal/models/tasks.go @@ -0,0 +1,57 @@ +package models + +import "time" + +// TaskTab selects which task list is being fetched. +type TaskTab string + +const ( + TaskTabMy TaskTab = "my" + TaskTabUnassigned TaskTab = "unassigned" +) + +func (t TaskTab) IsValid() bool { + switch t { + case TaskTabMy, TaskTabUnassigned: + return true + } + return false +} + +// TaskFilter captures GET /tasks query parameters. +type TaskFilter struct { + Tab TaskTab `query:"tab" validate:"required,oneof=my unassigned"` + Cursor string `query:"cursor"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100"` + Status string `query:"status" validate:"omitempty,oneof='pending' 'assigned' 'in progress' 'completed'"` + Department string `query:"department"` + Priority string `query:"priority" validate:"omitempty,oneof=low medium high urgent"` +} + +// Task is the staff-facing JSON shape for list/detail. +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Priority string `json:"priority"` + Department *string `json:"department,omitempty"` + Location string `json:"location"` + Description *string `json:"description,omitempty"` + DueTime *time.Time `json:"due_time,omitempty"` + Status string `json:"status"` + IsAssigned bool `json:"is_assigned"` + Cursor string `json:"-"` +} + +// PatchTaskBody is the body for PATCH /tasks/:id. +type PatchTaskBody struct { + Status string `json:"status" validate:"required,oneof='pending' 'assigned' 'in progress' 'completed'"` +} + +// CreateTaskBody is the body for POST /tasks (adhoc staff task). +type CreateTaskBody struct { + Name string `json:"name" validate:"required,notblank"` + AssignToMe bool `json:"assign_to_me"` + Description string `json:"description" validate:"omitempty"` + Priority string `json:"priority" validate:"omitempty,oneof=low medium high"` + Department string `json:"department" validate:"omitempty"` +} diff --git a/backend/internal/repository/requests.go b/backend/internal/repository/requests.go index fda27b9a4..f827329df 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -3,14 +3,37 @@ package repository import ( "context" "errors" + "fmt" + "strings" "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" + "github.com/generate/selfserve/internal/utils" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) +// requestsRowColumns matches public.requests column order for full-row scans (includes location_display). +const requestsRowColumns = `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, completed_at, notes, created_at, request_version, location_display` + +func scanRequestRow(scanner interface { + Scan(dest ...any) error +}) (*models.Request, error) { + var request models.Request + err := scanner.Scan( + &request.ID, &request.HotelID, &request.GuestID, &request.UserID, + &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.RequestVersion, &request.LocationDisplay, + ) + if err != nil { + return nil, err + } + return &request, nil +} + type RequestsRepository struct { db *pgxpool.Pool } @@ -29,9 +52,9 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ 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 + location_display, request_version, created_at ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), COALESCE((SELECT MIN(created_at) FROM requests WHERE id = $1), NOW()) ) @@ -39,7 +62,7 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ `, 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.RequestVersion) + req.ScheduledTime, req.Notes, req.LocationDisplay).Scan(&req.ID, &req.CreatedAt, &req.RequestVersion) if err != nil { return nil, err @@ -51,20 +74,14 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ func (r *RequestsRepository) FindRequest(ctx context.Context, id string) (*models.Request, error) { row := r.db.QueryRow(ctx, ` - SELECT * + SELECT `+requestsRowColumns+` 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.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.UserID, &request.RequestVersion) + request, err := scanRequestRow(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -73,18 +90,18 @@ func (r *RequestsRepository) FindRequest(ctx context.Context, id string) (*model return nil, err } - return &request, nil + return request, nil } func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, cursorTime time.Time, cursorID string, status string, hotelID string, pageSize int) ([]*models.Request, time.Time, string, error) { rows, err := r.db.Query(ctx, ` WITH latest AS ( - SELECT DISTINCT ON (id) * + SELECT DISTINCT ON (id) `+requestsRowColumns+` FROM requests WHERE status = $3 AND hotel_id = $4 ORDER BY id, request_version DESC ) - SELECT * FROM latest + SELECT `+requestsRowColumns+` FROM latest WHERE (request_version, id) > ($1, $2) ORDER BY request_version ASC, id ASC LIMIT $5 @@ -98,16 +115,11 @@ func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, requests := make([]*models.Request, 0) for rows.Next() { - var request models.Request - err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &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.UserID, &request.RequestVersion) - if err != nil { - return nil, time.Time{}, "", err + request, scanErr := scanRequestRow(rows) + if scanErr != nil { + return nil, time.Time{}, "", scanErr } - requests = append(requests, &request) + requests = append(requests, request) } if err := rows.Err(); err != nil { @@ -123,7 +135,7 @@ func (r *RequestsRepository) FindRequestsByStatusPaginated(ctx context.Context, } func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request, error) { - rows, err := r.db.Query(ctx, `SELECT * FROM requests ORDER BY created_at DESC`) + rows, err := r.db.Query(ctx, `SELECT `+requestsRowColumns+` FROM requests ORDER BY created_at DESC`) if err != nil { return nil, err } @@ -131,16 +143,11 @@ func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request var requests []models.Request for rows.Next() { - var request models.Request - err := rows.Scan(&request.ID, &request.HotelID, &request.GuestID, - &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.UserID, &request.RequestVersion) - if err != nil { - return nil, err + request, scanErr := scanRequestRow(rows) + if scanErr != nil { + return nil, scanErr } - requests = append(requests, request) + requests = append(requests, *request) } if err := rows.Err(); err != nil { @@ -246,3 +253,173 @@ func scanGuestRequests(rows pgx.Rows) ([]*models.GuestRequest, error) { } return requests, rows.Err() } + +// FindTasks returns up to limit+1 tasks for cursor pagination (newest sort keys first within each tab). +func (r *RequestsRepository) FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) { + limit := utils.ResolveLimit(filter.Limit) + 1 + tab := string(filter.Tab) + statusOv := strings.TrimSpace(filter.Status) + deptF := strings.TrimSpace(filter.Department) + priF := strings.TrimSpace(filter.Priority) + + orderBy := `r.pr DESC, r.created_at DESC, r.id DESC` + if filter.Tab == models.TaskTabUnassigned { + orderBy = `r.dk ASC, r.pr DESC, r.created_at DESC, r.id DESC` + } + + args := []any{hotelID, tab, clerkUserID, statusOv, deptF, priF} + next := 7 + var cursorSQL string + if hasCursor { + if filter.Tab == models.TaskTabMy { + cursorSQL = fmt.Sprintf( + ` AND (r.pr < $%d OR (r.pr = $%d AND r.created_at < $%d) OR (r.pr = $%d AND r.created_at = $%d AND r.id::uuid < $%d::uuid))`, + next, next, next+1, next, next+1, next+2, + ) + args = append(args, cursorRank, cursorCreatedAt, cursorID) + next += 3 + } else { + cursorSQL = fmt.Sprintf( + ` AND (r.dk > $%d OR (r.dk = $%d AND (r.pr < $%d OR (r.pr = $%d AND r.created_at < $%d) OR (r.pr = $%d AND r.created_at = $%d AND r.id::uuid < $%d::uuid))))`, + next, next, next+1, next+1, next+2, next+1, next+2, next+3, + ) + args = append(args, cursorDeptKey, cursorRank, cursorCreatedAt, cursorID) + next += 4 + } + } + args = append(args, limit) + limitParam := next + + query := fmt.Sprintf(` +WITH latest AS ( + SELECT DISTINCT ON (r.id) + r.id, r.user_id, r.name, r.priority, r.department, r.status, r.description, r.scheduled_time, r.created_at, + r.room_id, r.location_display + FROM requests r + WHERE r.hotel_id = $1 + ORDER BY r.id ASC, r.request_version DESC +), +ranked AS ( + SELECT + l.*, + CASE LOWER(TRIM(COALESCE(l.priority, ''))) + WHEN 'urgent' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'middle' THEN 2 WHEN 'low' THEN 1 ELSE 0 + END AS pr, + LOWER(TRIM(COALESCE(l.department, ''))) AS dk + FROM latest l + WHERE + ($2 = 'my' AND l.user_id = $3 AND (($4 <> '' AND l.status = $4) OR ($4 = '' AND l.status IN ('assigned', 'in progress')))) + OR + ($2 = 'unassigned' AND l.user_id IS NULL AND (($4 <> '' AND l.status = $4) OR ($4 = '' AND l.status = 'pending'))) +) +SELECT + r.id, + r.name, + r.priority, + r.department, + r.status, + r.description, + r.scheduled_time, + r.created_at, + (r.user_id IS NOT NULL AND r.user_id <> '') AS is_assigned, + CASE + WHEN r.location_display IS NOT NULL AND TRIM(r.location_display) <> '' THEN TRIM(r.location_display) + WHEN r.room_id IS NOT NULL AND TRIM(r.room_id::text) <> '' THEN 'Room ' || r.room_id::text + ELSE 'Room unavailable' + END AS loc +FROM ranked r +WHERE ($5 = '' OR r.dk = LOWER(TRIM($5))) + AND ($6 = '' OR r.priority = $6) +%s +ORDER BY %s +LIMIT $%d`, cursorSQL, orderBy, limitParam) + + rows, err := r.db.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []models.Task + for rows.Next() { + var t models.Task + var dept *string + var createdAt time.Time + if err := rows.Scan( + &t.ID, &t.Title, &t.Priority, &dept, &t.Status, &t.Description, &t.DueTime, &createdAt, &t.IsAssigned, &t.Location, + ); err != nil { + return nil, err + } + t.Department = dept + cur, err := utils.EncodeTaskCursor(filter.Tab, utils.PriorityRank(t.Priority), utils.DepartmentKey(dept), createdAt, t.ID) + if err != nil { + return nil, err + } + t.Cursor = cur + out = append(out, t) + } + return out, rows.Err() +} + +// UpdateTaskStatus inserts a new request version with an updated status. +func (r *RequestsRepository) UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID != nil && strings.TrimSpace(*base.UserID) != "" && *base.UserID != clerkUserID { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + mr.Status = newStatus + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} + +// ClaimTask assigns a pending unassigned task to the given staff user (Clerk id). +func (r *RequestsRepository) ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID != nil && strings.TrimSpace(*base.UserID) != "" { + return errs.ErrTaskStateConflict + } + if base.Status != string(models.StatusPending) { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + u := clerkUserID + mr.UserID = &u + mr.Status = string(models.StatusAssigned) + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} + +// DropTask returns a task to the unassigned pool. +func (r *RequestsRepository) DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error { + base, err := r.FindRequest(ctx, requestID) + if err != nil { + return err + } + if base.HotelID != hotelID { + return errs.ErrNotFoundInDB + } + if base.UserID == nil || *base.UserID != clerkUserID { + return errs.ErrTaskStateConflict + } + if base.Status != string(models.StatusAssigned) && base.Status != string(models.StatusInProgress) { + return errs.ErrTaskStateConflict + } + mr := base.MakeRequest + mr.UserID = nil + mr.Status = string(models.StatusPending) + _, err = r.InsertRequest(ctx, &models.Request{ID: base.ID, MakeRequest: mr}) + return err +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index c7e059a1b..1421ca16c 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -140,8 +140,10 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo devsHandler := handler.NewDevsHandler(repository.NewDevsRepository(repo.DB)) usersHandler := handler.NewUsersHandler(repository.NewUsersRepository(repo.DB), s3Store) guestsHandler := handler.NewGuestsHandler(repository.NewGuestsRepository(repo.DB), openSearchRepos.Guests) - reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) - hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB), repository.NewUsersRepository(repo.DB)) + reqsRepo := repository.NewRequestsRepo(repo.DB) + reqsHandler := handler.NewRequestsHandler(reqsRepo, genkitInstance, notifService) + tasksHandler := handler.NewTasksHandler(reqsRepo, usersRepo) + 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)) @@ -208,6 +210,15 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo r.Get("/room/:id", reqsHandler.GetRequestsByRoomID) }) + // Staff tasks (requests as tasks) + api.Route("/tasks", func(r fiber.Router) { + r.Get("/", tasksHandler.GetTasks) + r.Post("/", tasksHandler.CreateTask) + r.Patch("/:id", tasksHandler.PatchTask) + r.Post("/:id/claim", tasksHandler.ClaimTask) + r.Post("/:id/drop", tasksHandler.DropTask) + }) + // Hotel routes api.Route("/hotels", func(r fiber.Router) { r.Get("/:id", hotelsHandler.GetHotelByID) @@ -274,7 +285,7 @@ func setupApp() *fiber.App { allowedOrigins := os.Getenv("APP_CORS_ORIGINS") app.Use(cors.New(cors.Config{ AllowOrigins: allowedOrigins, - AllowMethods: "GET,POST,PUT,DELETE", + AllowMethods: "GET,POST,PUT,PATCH,DELETE", AllowHeaders: "Origin, Content-Type, Authorization, X-Hotel-ID", AllowCredentials: true, })) diff --git a/backend/internal/service/storage/postgres/repo_types.go b/backend/internal/service/storage/postgres/repo_types.go index 7a17cf86f..965cc6d52 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -52,6 +52,10 @@ type RequestsRepository interface { FindRequests(ctx context.Context) ([]models.Request, error) FindRequestsByStatusPaginated(ctx context.Context, cursorTime time.Time, cursorID string, status string, hotelID string, pageSize int) ([]*models.Request, time.Time, string, error) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) + FindTasks(ctx context.Context, hotelID, clerkUserID string, filter models.TaskFilter, cursorRank int, cursorDeptKey string, cursorCreatedAt time.Time, cursorID string, hasCursor bool) ([]models.Task, error) + UpdateTaskStatus(ctx context.Context, hotelID, requestID, clerkUserID, newStatus string) error + ClaimTask(ctx context.Context, hotelID, requestID, clerkUserID string) error + DropTask(ctx context.Context, hotelID, requestID, clerkUserID string) error FindMyRequestsByRoomID(ctx context.Context, roomID, hotelID, userID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) FindUnassignedRequestsByRoomID(ctx context.Context, roomID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) } diff --git a/backend/internal/utils/cursor_pagination.go b/backend/internal/utils/cursor_pagination.go index 41e71643c..e07b9614a 100644 --- a/backend/internal/utils/cursor_pagination.go +++ b/backend/internal/utils/cursor_pagination.go @@ -21,6 +21,9 @@ type CursorPage[T any] struct { } func BuildCursorPage[T any](items []T, limit int, cursorFn func(T) string) CursorPage[T] { + if items == nil { + items = []T{} + } limit = ResolveLimit(limit) hasMore := len(items) > limit if hasMore { diff --git a/backend/internal/utils/task_cursor.go b/backend/internal/utils/task_cursor.go new file mode 100644 index 000000000..8179ad902 --- /dev/null +++ b/backend/internal/utils/task_cursor.go @@ -0,0 +1,89 @@ +package utils + +import ( + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/generate/selfserve/internal/models" +) + +const taskCursorVersion = 2 + +type taskCursorPayload struct { + V int `json:"v"` + Tab string `json:"tab"` + PR int `json:"pr"` + DK string `json:"dk"` + CA time.Time `json:"ca"` + ID string `json:"id"` +} + +// PriorityRank maps stored priority strings to a numeric rank (higher = more urgent). +func PriorityRank(priority string) int { + switch strings.ToLower(strings.TrimSpace(priority)) { + case "urgent": + return 4 + case "high": + return 3 + case "medium", "middle": + return 2 + case "low": + return 1 + default: + return 0 + } +} + +// DepartmentKey normalizes department for sorting and cursors. +func DepartmentKey(dept *string) string { + if dept == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*dept)) +} + +// EncodeTaskCursor builds an opaque cursor for the given sort key. +func EncodeTaskCursor(tab models.TaskTab, priorityRank int, deptKey string, createdAt time.Time, id string) (string, error) { + p := taskCursorPayload{ + V: taskCursorVersion, + Tab: string(tab), + PR: priorityRank, + DK: deptKey, + CA: createdAt.UTC(), + ID: id, + } + raw, err := json.Marshal(p) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// DecodeTaskCursor parses and validates a cursor; returns payload fields. +func DecodeTaskCursor(encoded string, expectedTab models.TaskTab) (priorityRank int, deptKey string, createdAt time.Time, id string, err error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return 0, "", time.Time{}, "", errors.New("empty cursor") + } + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return 0, "", time.Time{}, "", errors.New("invalid cursor encoding") + } + var p taskCursorPayload + if err := json.Unmarshal(raw, &p); err != nil { + return 0, "", time.Time{}, "", errors.New("invalid cursor payload") + } + if p.V != taskCursorVersion { + return 0, "", time.Time{}, "", errors.New("unsupported cursor version") + } + if models.TaskTab(p.Tab) != expectedTab { + return 0, "", time.Time{}, "", errors.New("cursor does not match tab") + } + if p.ID == "" { + return 0, "", time.Time{}, "", errors.New("invalid cursor id") + } + return p.PR, p.DK, p.CA.UTC(), p.ID, nil +} diff --git a/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql b/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql new file mode 100644 index 000000000..554f88af6 --- /dev/null +++ b/backend/supabase/migrations/20260404100000_add_location_display_to_requests.sql @@ -0,0 +1 @@ +ALTER TABLE public.requests ADD COLUMN IF NOT EXISTS location_display text; diff --git a/clients/mobile/app/(tabs)/tasks.tsx b/clients/mobile/app/(tabs)/tasks.tsx index b078eb537..95a7397a4 100644 --- a/clients/mobile/app/(tabs)/tasks.tsx +++ b/clients/mobile/app/(tabs)/tasks.tsx @@ -1,54 +1,215 @@ -import { useState } from "react"; -import { View } from "react-native"; +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { ActiveFilterChips } from "@/components/tasks/active-filter-chips"; +import { + ActiveFilterChips, + type ActiveFilterChip, +} from "@/components/tasks/active-filter-chips"; import { TabBar } from "@/components/tasks/tab-bar"; +import { TaskCompletionModal } from "@/components/tasks/task-completion-modal"; +import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet"; +import { TaskFilterSheet } from "@/components/tasks/task-filter-sheet"; import { TaskList } from "@/components/tasks/task-list"; import { TasksHeader } from "@/components/tasks/tasks-header"; -import { TAB, TabName, TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; -import { myTasks, unassignedTasks } from "@/data/mockTasks"; - -const tabConfigs: Record< - TabName, - { - tasks: typeof myTasks; - variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; - showFilters: boolean; +import { + TAB, + TASK_ASSIGNMENT_STATE, + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + type TabName, +} from "@/constants/tasks"; +import { useTaskMutations, useTasksFeed } from "@/hooks/use-tasks-feed"; +import type { Task, TasksFilterState } from "@/types/tasks"; + +function filterChips( + tab: TabName, + filters: TasksFilterState, +): ActiveFilterChip[] { + const out: ActiveFilterChip[] = []; + if (filters.department) { + const d = TASK_FILTER_DEPARTMENTS.find( + (x) => x.value === filters.department, + ); + out.push({ + field: "department", + label: `Dept: ${d?.label ?? filters.department}`, + }); + } + if (filters.priority) { + const p = TASK_FILTER_PRIORITIES.find((x) => x.value === filters.priority); + out.push({ + field: "priority", + label: `Priority: ${p?.label ?? filters.priority}`, + }); + } + if (filters.status) { + const pool = + tab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED; + const s = pool.find((x) => x.value === filters.status); + out.push({ + field: "status", + label: `Status: ${s?.label ?? filters.status}`, + }); } -> = { - [TAB.MY_TASKS]: { - tasks: myTasks, - variant: TASK_ASSIGNMENT_STATE.ASSIGNED, - showFilters: false, - }, - [TAB.UNASSIGNED]: { - tasks: unassignedTasks, - variant: TASK_ASSIGNMENT_STATE.UNASSIGNED, - showFilters: true, - }, -}; + return out; +} export default function TasksScreen() { const [activeTab, setActiveTab] = useState(TAB.MY_TASKS); - const currentTab = tabConfigs[activeTab]; + const [filters, setFilters] = useState({}); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [completeOpen, setCompleteOpen] = useState(false); + + const filterSheetRef = useRef(null); + const detailSheetRef = useRef(null); + + const { + flatTasks, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending, + isRefetching, + error, + refetch, + } = useTasksFeed(activeTab, filters); + + const { patchStatus, claimTask, dropTask } = useTaskMutations(); + const mutating = + patchStatus.isPending || claimTask.isPending || dropTask.isPending; + + const displayed = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return flatTasks; + return flatTasks.filter( + (t) => + t.title.toLowerCase().includes(q) || + (t.description?.toLowerCase().includes(q) ?? false), + ); + }, [flatTasks, searchQuery]); + + const variant = + activeTab === TAB.MY_TASKS + ? TASK_ASSIGNMENT_STATE.ASSIGNED + : TASK_ASSIGNMENT_STATE.UNASSIGNED; + + const chips = useMemo( + () => filterChips(activeTab, filters), + [activeTab, filters], + ); + + const openDetail = useCallback((t: Task) => { + setSelected(t); + detailSheetRef.current?.present(); + }, []); + + const handleStart = useCallback( + async (id: string) => { + await patchStatus.mutateAsync({ id, status: "in progress" }); + detailSheetRef.current?.dismiss(); + }, + [patchStatus], + ); + + const handleComplete = useCallback( + async (id: string) => { + await patchStatus.mutateAsync({ id, status: "completed" }); + detailSheetRef.current?.dismiss(); + setCompleteOpen(true); + }, + [patchStatus], + ); + + const handleClaim = useCallback( + async (id: string) => { + await claimTask.mutateAsync(id); + detailSheetRef.current?.dismiss(); + }, + [claimTask], + ); + + const handleDrop = useCallback( + async (id: string) => { + await dropTask.mutateAsync(id); + detailSheetRef.current?.dismiss(); + }, + [dropTask], + ); return ( - + setSearchOpen((v) => !v)} + searchQuery={searchQuery} + onSearchQuery={setSearchQuery} + onOpenFilters={() => filterSheetRef.current?.present()} + /> - {currentTab.showFilters && ( + {chips.length > 0 ? ( {}} - onClearAll={() => {}} + filters={chips} + onRemoveFilter={(field) => + setFilters((f) => ({ ...f, [field]: undefined })) + } + onClearAll={() => setFilters({})} /> - )} + ) : null} - + {error ? ( + + {(error as Error).message || "Failed to load tasks"} + + ) : null} + {isPending && !flatTasks.length ? ( + Loading… + ) : ( + { + if (hasNextPage && !isFetchingNextPage) void fetchNextPage(); + }} + listFooter={isFetchingNextPage} + refreshing={isRefetching} + onRefresh={() => void refetch()} + /> + )} + + + + setCompleteOpen(false)} + /> ); } diff --git a/clients/mobile/components/tasks/active-filter-chips.tsx b/clients/mobile/components/tasks/active-filter-chips.tsx index c6b8ee592..84c52b569 100644 --- a/clients/mobile/components/tasks/active-filter-chips.tsx +++ b/clients/mobile/components/tasks/active-filter-chips.tsx @@ -1,9 +1,14 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; +export type ActiveFilterChip = { + field: "department" | "priority" | "status"; + label: string; +}; + interface ActiveFilterChipsProps { - filters: { label: string; value: string }[]; - onRemoveFilter: (value: string) => void; + filters: ActiveFilterChip[]; + onRemoveFilter: (field: ActiveFilterChip["field"]) => void; onClearAll: () => void; } @@ -13,14 +18,14 @@ export function ActiveFilterChips({ onClearAll, }: ActiveFilterChipsProps) { return ( - + {filters.map((filter) => ( {filter.label} - onRemoveFilter(filter.value)}> + onRemoveFilter(filter.field)}> diff --git a/clients/mobile/components/tasks/task-card.tsx b/clients/mobile/components/tasks/task-card.tsx index ebdfeefae..8b022a80d 100644 --- a/clients/mobile/components/tasks/task-card.tsx +++ b/clients/mobile/components/tasks/task-card.tsx @@ -2,46 +2,65 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; import { TaskBadge } from "@/components/tasks/task-badge"; -import type { Task } from "@/data/mockTasks"; import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; interface TaskCardProps { task: Task; variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; isExpanded: boolean; + busy: boolean; + onOpenDetail: () => void; + onPrimary: () => void; } function DotSeparator() { return ; } -export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { +export function TaskCard({ + task, + variant, + isExpanded, + busy, + onOpenDetail, + onPrimary, +}: TaskCardProps) { const isAssigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; if (isAssigned && isExpanded) { return ( - {task.title} - - {task.priority} - - {task.department} - {task.dueTime && ( - <> - - {task.dueTime} - - )} - - {task.description && ( - {task.description} - )} - {}} - className="bg-blue-600 rounded-lg py-3 w-full items-center mt-3" - > - Start + + {task.title} + + {task.priority} + + {task.department} + {task.dueTime ? ( + <> + + {task.dueTime} + + ) : null} + + {task.description ? ( + + {task.description} + + ) : null} + {(task.status === "assigned" || task.status === "in progress") && ( + + + {task.status === "in progress" ? "Mark done" : "Start"} + + + )} ); } @@ -49,14 +68,14 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { if (isAssigned && !isExpanded) { return ( - + {task.title} - - {}} className="p-1"> + + @@ -66,17 +85,22 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { if (!isAssigned && isExpanded) { return ( - {task.title} - - - - - - {task.description && ( - {task.description} - )} + + {task.title} + + + + + + {task.description ? ( + + {task.description} + + ) : null} + {}} + disabled={busy} + onPress={onPrimary} className="bg-white border border-gray-300 rounded-lg py-3 w-full items-center mt-3" > Claim Task @@ -85,18 +109,17 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { ); } - // Compact unassigned return ( - + {task.title} - - {}} className="p-1"> + + diff --git a/clients/mobile/components/tasks/task-completion-modal.tsx b/clients/mobile/components/tasks/task-completion-modal.tsx new file mode 100644 index 000000000..e02e0668e --- /dev/null +++ b/clients/mobile/components/tasks/task-completion-modal.tsx @@ -0,0 +1,32 @@ +import { Modal, Pressable, Text, View } from "react-native"; + +type TaskCompletionModalProps = { + visible: boolean; + onClose: () => void; +}; + +export function TaskCompletionModal({ + visible, + onClose, +}: TaskCompletionModalProps) { + return ( + + + + Task completed + + TODO: Add manager notes and confetti. + + + + Done + + + + + + ); +} diff --git a/clients/mobile/components/tasks/task-detail-sheet.tsx b/clients/mobile/components/tasks/task-detail-sheet.tsx new file mode 100644 index 000000000..2a9e808fc --- /dev/null +++ b/clients/mobile/components/tasks/task-detail-sheet.tsx @@ -0,0 +1,220 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import React, { useCallback } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; + +import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; + +/** Figma SelfServe task detail sheet — Primary */ +const PRIMARY_BLUE = "#004FC5"; +const LABEL_MUTED = "#A4A4A4"; + +type TaskDetailSheetProps = { + sheetRef: React.RefObject; + task: Task | null; + variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + onStart: (id: string) => void; + onComplete: (id: string) => void; + onClaim: (id: string) => void; + onDrop: (id: string) => void; + busy: boolean; +}; + +function MetadataGlyph() { + return ( + + + + + + + + + + + ); +} + +function MetadataRow({ label, value }: { label: string; value: string }) { + return ( + + + + + {label} + + + + + {value} + + + + ); +} + +export function TaskDetailSheet({ + sheetRef, + task, + variant, + onStart, + onComplete, + onClaim, + onDrop, + busy, +}: TaskDetailSheetProps) { + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const assigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; + + const onHistoryPress = useCallback(() => { + Alert.alert( + "Task history", + "Activity history for this task is not available yet.", + ); + }, []); + + const deadlineDisplay = + task?.dueTime?.trim() && task.dueTime !== "—" ? task.dueTime : "Not set"; + + return ( + + + {!task ? null : ( + <> + + + {task.title} + + + + + + + + + + + + + + + + + Description + + + {task.description?.trim() + ? task.description + : "No description provided."} + + + + + + {assigned && task.status === "assigned" ? ( + onStart(task.id)} + className="w-full h-10 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} + > + + Start + + + ) : null} + {assigned && task.status === "in progress" ? ( + onComplete(task.id)} + className="w-full h-10 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} + > + + Mark done + + + ) : null} + {assigned && + (task.status === "assigned" || task.status === "in progress") ? ( + onDrop(task.id)} + className="w-full h-10 px-6 items-center justify-center rounded-lg bg-white border border-neutral-300 active:bg-neutral-50" + > + + Drop Task + + + ) : null} + {!assigned ? ( + onClaim(task.id)} + className="w-full h-11 px-6 items-center justify-center rounded-lg active:opacity-90" + style={{ backgroundColor: PRIMARY_BLUE }} + > + + Claim Task + + + ) : null} + + + )} + + + ); +} diff --git a/clients/mobile/components/tasks/task-filter-sheet.tsx b/clients/mobile/components/tasks/task-filter-sheet.tsx new file mode 100644 index 000000000..e85453480 --- /dev/null +++ b/clients/mobile/components/tasks/task-filter-sheet.tsx @@ -0,0 +1,170 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import React, { useCallback, useMemo, useState } from "react"; +import { Pressable, Text, useWindowDimensions, View } from "react-native"; + +import { + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + TAB, + type TabName, +} from "@/constants/tasks"; +import type { TasksFilterState } from "@/types/tasks"; + +/** Sheet height = window height × this (smaller = sits lower on screen). */ +const FILTER_SHEET_HEIGHT_FRACTION = 0.82; + +type TaskFilterSheetProps = { + sheetRef: React.RefObject; + activeTab: TabName; + applied: TasksFilterState; + onApply: (next: TasksFilterState) => void; +}; + +function Chip({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +export function TaskFilterSheet({ + sheetRef, + activeTab, + applied, + onApply, +}: TaskFilterSheetProps) { + const { height: windowHeight } = useWindowDimensions(); + const [draft, setDraft] = useState(applied); + + const statusOptions = useMemo( + () => + activeTab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED, + [activeTab], + ); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const openSync = () => setDraft({ ...applied }); + + const snapPoints = useMemo( + () => [Math.round(windowHeight * FILTER_SHEET_HEIGHT_FRACTION)], + [windowHeight, FILTER_SHEET_HEIGHT_FRACTION], + ); + + return ( + { + if (i >= 0) openSync(); + }} + > + + Filters + + + Department + + + {TASK_FILTER_DEPARTMENTS.map((d) => ( + + setDraft((s) => ({ + ...s, + department: s.department === d.value ? undefined : d.value, + })) + } + /> + ))} + + + + Priority + + + {TASK_FILTER_PRIORITIES.map((p) => ( + + setDraft((s) => ({ + ...s, + priority: s.priority === p.value ? undefined : p.value, + })) + } + /> + ))} + + + Status + + {statusOptions.map((s) => ( + + setDraft((prev) => ({ + ...prev, + status: prev.status === s.value ? undefined : s.value, + })) + } + /> + ))} + + + { + onApply(draft); + sheetRef.current?.dismiss(); + }} + className="bg-blue-600 rounded-xl py-4 items-center" + > + Apply + + + + ); +} diff --git a/clients/mobile/components/tasks/task-list.tsx b/clients/mobile/components/tasks/task-list.tsx index 1de565104..b940b0cc3 100644 --- a/clients/mobile/components/tasks/task-list.tsx +++ b/clients/mobile/components/tasks/task-list.tsx @@ -1,21 +1,64 @@ -import { FlatList, ListRenderItem } from "react-native"; +import { + ActivityIndicator, + FlatList, + ListRenderItem, + RefreshControl, + View, +} from "react-native"; import { TaskCard } from "@/components/tasks/task-card"; -import type { Task } from "@/data/mockTasks"; import { TASK_ASSIGNMENT_STATE } from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; interface TaskListProps { tasks: Task[]; variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + onOpenDetail: (task: Task) => void; + onStart: (id: string) => void; + onComplete: (id: string) => void; + onClaim: (id: string) => void; + isMutating: boolean; + onEndReached: () => void; + listFooter: boolean; + refreshing: boolean; + onRefresh: () => void; } -export function TaskList({ tasks, variant }: TaskListProps) { +export function TaskList({ + tasks, + variant, + onOpenDetail, + onStart, + onComplete, + onClaim, + isMutating, + onEndReached, + listFooter, + refreshing, + onRefresh, +}: TaskListProps) { const renderItem: ListRenderItem = ({ item, index }) => { const isExpanded = variant === TASK_ASSIGNMENT_STATE.ASSIGNED ? index === 0 - : item.priority === "High"; - return ; + : item.priority.toLowerCase() === "high"; + return ( + onOpenDetail(item)} + onPrimary={() => { + if (variant === TASK_ASSIGNMENT_STATE.UNASSIGNED) { + onClaim(item.id); + return; + } + if (item.status === "assigned") onStart(item.id); + else if (item.status === "in progress") onComplete(item.id); + }} + /> + ); }; return ( @@ -23,8 +66,20 @@ export function TaskList({ tasks, variant }: TaskListProps) { data={tasks} keyExtractor={(item) => item.id} renderItem={renderItem} - contentContainerClassName="px-[5vw] py-4 gap-4" + contentContainerClassName="px-5 py-4 gap-4" showsVerticalScrollIndicator={false} + onEndReached={onEndReached} + onEndReachedThreshold={0.35} + refreshControl={ + + } + ListFooterComponent={ + listFooter ? ( + + + + ) : null + } /> ); } diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index d21ed55fe..5ded8ce09 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -1,21 +1,48 @@ import Feather from "@expo/vector-icons/Feather"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, TextInput, View } from "react-native"; -export function TasksHeader() { +type TasksHeaderProps = { + searchOpen: boolean; + onToggleSearch: () => void; + searchQuery: string; + onSearchQuery: (q: string) => void; + onOpenFilters: () => void; +}; + +export function TasksHeader({ + searchOpen, + onToggleSearch, + searchQuery, + onSearchQuery, + onOpenFilters, +}: TasksHeaderProps) { return ( - - Tasks - - {}}> - - - {}}> - - - {}}> - - + + + Tasks + + + + + + + + {}}> + + + + {searchOpen ? ( + + ) : null} ); } diff --git a/clients/mobile/constants/tasks.ts b/clients/mobile/constants/tasks.ts index d5f229d45..053cde70d 100644 --- a/clients/mobile/constants/tasks.ts +++ b/clients/mobile/constants/tasks.ts @@ -12,3 +12,33 @@ export const TASK_ASSIGNMENT_STATE = { export type TaskAssignmentState = (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + +/** API `tab` query value */ +export function tabToApi(tab: TabName): "my" | "unassigned" { + return tab === TAB.MY_TASKS ? "my" : "unassigned"; +} + +export const TASK_FILTER_DEPARTMENTS = [ + { label: "Housekeeping", value: "Housekeeping" }, + { label: "Room Service", value: "Room Service" }, + { label: "Maintenance", value: "Maintenance" }, + { label: "Front Desk", value: "Front Desk" }, +] as const; + +export const TASK_FILTER_PRIORITIES = [ + { label: "Low", value: "low" }, + { label: "Medium", value: "medium" }, + { label: "High", value: "high" }, +] as const; + +/** Status values for `my` tab (API enum). */ +export const TASK_FILTER_STATUS_MY = [ + { label: "Assigned", value: "assigned" }, + { label: "In progress", value: "in progress" }, + { label: "Completed", value: "completed" }, +] as const; + +/** Status values for unassigned tab. */ +export const TASK_FILTER_STATUS_UNASSIGNED = [ + { label: "Pending", value: "pending" }, +] as const; diff --git a/clients/mobile/data/mockTasks.ts b/clients/mobile/data/mockTasks.ts deleted file mode 100644 index 11c2f01d2..000000000 --- a/clients/mobile/data/mockTasks.ts +++ /dev/null @@ -1,99 +0,0 @@ -export type Priority = "High" | "Middle" | "Low"; -export type Department = - | "Housekeeping" - | "Room Service" - | "Maintenance" - | "Front Desk"; - -export interface Task { - id: string; - title: string; - priority: Priority; - department: Department; - location: string; - description?: string; - dueTime?: string; - isAssigned: boolean; -} - -export const myTasks: Task[] = [ - { - id: "1", - title: "Clean Up Spill", - priority: "High", - department: "Housekeeping", - location: "Floor 3, Room 2A", - description: "Carpet cleaner needed", - dueTime: "Today, 11:30am", - isAssigned: true, - }, - { - id: "2", - title: "Vacuum Carpet", - priority: "Middle", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "3", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "4", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, - { - id: "5", - title: "Vacuum Carpet", - priority: "Low", - department: "Housekeeping", - location: "Floor 3, Room 2A", - isAssigned: true, - }, -]; - -export const unassignedTasks: Task[] = [ - { - id: "6", - title: "Breakfast for VIP", - priority: "High", - department: "Room Service", - location: "Floor 30, Penthouse 2", - description: "Lorem Ipsum dolor sit amet description here...", - isAssigned: false, - }, - { - id: "7", - title: "Cleanup Brunch Cart", - priority: "High", - department: "Room Service", - location: "Floor 30, Penthouse 5", - description: "Lorem Ipsum dolor sit amet description here...", - isAssigned: false, - }, - { - id: "8", - title: "Reheated Towels for Guest", - priority: "Middle", - department: "Room Service", - location: "Floor 4", - isAssigned: false, - }, - { - id: "9", - title: "Steamed Blankets & Pillowcases for Family of 5", - priority: "Middle", - department: "Room Service", - location: "Floor 4", - isAssigned: false, - }, -]; diff --git a/clients/mobile/hooks/use-tasks-feed.ts b/clients/mobile/hooks/use-tasks-feed.ts new file mode 100644 index 000000000..4770cdf3d --- /dev/null +++ b/clients/mobile/hooks/use-tasks-feed.ts @@ -0,0 +1,132 @@ +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { + API_ENDPOINTS, + useAPIClient, + usePatchTasksId, + usePostTasksIdClaim, + usePostTasksIdDrop, +} from "@shared"; + +import { TAB, type TabName, tabToApi } from "@/constants/tasks"; +import type { + BackendTask, + CursorPage, + Task, + TasksFilterState, +} from "@/types/tasks"; +import { mapBackendTask } from "@/types/tasks"; + +function buildTaskParams( + tab: TabName, + filters: TasksFilterState, + cursor: string, +): Record { + const params: Record = { + tab: tabToApi(tab), + limit: "20", + }; + if (cursor) params.cursor = cursor; + if (filters.department) params.department = filters.department; + if (filters.priority) params.priority = filters.priority; + if (filters.status) params.status = filters.status; + return params; +} + +export function useTasksFeed(tab: TabName, filters: TasksFilterState) { + const client = useAPIClient(); + + const query = useInfiniteQuery({ + queryKey: ["tasks-feed", tab, filters] as const, + initialPageParam: "", + queryFn: async ({ pageParam }) => { + const params = buildTaskParams(tab, filters, pageParam); + return client.get>(API_ENDPOINTS.TASKS, params); + }, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + }); + + const flatTasks: Task[] = (query.data?.pages ?? []).flatMap((p) => + (p.items ?? []).map(mapBackendTask), + ); + + return { ...query, flatTasks }; +} + +export function useTaskMutations() { + const qc = useQueryClient(); + + const invalidate = () => qc.invalidateQueries({ queryKey: ["tasks-feed"] }); + + const patchStatusMutation = usePatchTasksId({ + mutation: { + onSettled: invalidate, + }, + }); + type PatchStatusInput = { + id: string; + status: Parameters< + typeof patchStatusMutation.mutateAsync + >[0]["data"]["status"]; + }; + const patchStatus = { + ...patchStatusMutation, + mutate: ( + vars: PatchStatusInput, + ...args: Parameters extends [ + any, + ...infer R, + ] + ? R + : never + ) => + patchStatusMutation.mutate( + { id: vars.id, data: { status: vars.status } }, + ...args, + ), + mutateAsync: (vars: PatchStatusInput) => + patchStatusMutation.mutateAsync({ + id: vars.id, + data: { status: vars.status }, + }), + }; + + const claimTaskMutation = usePostTasksIdClaim({ + mutation: { + onSettled: invalidate, + }, + }); + const claimTask = { + ...claimTaskMutation, + mutate: ( + id: string, + ...args: Parameters extends [ + any, + ...infer R, + ] + ? R + : never + ) => claimTaskMutation.mutate({ id }, ...args), + mutateAsync: (id: string) => claimTaskMutation.mutateAsync({ id }), + }; + + const dropTaskMutation = usePostTasksIdDrop({ + mutation: { + onSettled: invalidate, + }, + }); + const dropTask = { + ...dropTaskMutation, + mutate: ( + id: string, + ...args: Parameters extends [ + any, + ...infer R, + ] + ? R + : never + ) => dropTaskMutation.mutate({ id }, ...args), + mutateAsync: (id: string) => dropTaskMutation.mutateAsync({ id }), + }; + + return { patchStatus, claimTask, dropTask }; +} diff --git a/clients/mobile/package-lock.json b/clients/mobile/package-lock.json index 80d3f006c..3b94501c0 100644 --- a/clients/mobile/package-lock.json +++ b/clients/mobile/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@clerk/clerk-expo": "^2.19.23", "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-picker/picker": "^2.11.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -2411,6 +2412,45 @@ "integrity": "sha512-DHHC01EJ1p70Q0z/ZFRBIY8NDnmfKccQoyoM84Tgb6omLMat6jivCdf272Y8k3nf4Lzdin/Y4R9q8uFtU0GbnA==", "license": "MIT" }, + "node_modules/@gorhom/bottom-sheet": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz", + "integrity": "sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA==", + "license": "MIT", + "dependencies": { + "@gorhom/portal": "1.0.14", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-native": "*", + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=3.16.0 || >=4.0.0-" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-native": { + "optional": true + } + } + }, + "node_modules/@gorhom/portal": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz", + "integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, diff --git a/clients/mobile/package.json b/clients/mobile/package.json index 9e248654d..faed59921 100644 --- a/clients/mobile/package.json +++ b/clients/mobile/package.json @@ -17,6 +17,7 @@ "dependencies": { "@clerk/clerk-expo": "^2.19.23", "@expo/vector-icons": "^15.0.3", + "@gorhom/bottom-sheet": "^5.2.8", "@react-native-picker/picker": "^2.11.4", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", @@ -60,9 +61,9 @@ "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", - "prettier": "^3.5.3", "jest": "~29.7.0", "jest-expo": "^54.0.16", + "prettier": "^3.5.3", "react-native-dotenv": "^3.4.11", "react-test-renderer": "^19.1.0", "tailwindcss": "^3.4.19", diff --git a/clients/mobile/types/tasks.ts b/clients/mobile/types/tasks.ts new file mode 100644 index 000000000..e1a56ee42 --- /dev/null +++ b/clients/mobile/types/tasks.ts @@ -0,0 +1,65 @@ +/** API row from GET /tasks (matches backend models.Task). */ +export type BackendTask = { + id: string; + title: string; + priority: string; + department?: string | null; + location: string; + description?: string | null; + due_time?: string | null; + status: string; + is_assigned: boolean; +}; + +export type CursorPage = { + /** Backend may send `null` for empty lists (Go nil slice JSON). */ + items: T[] | null; + next_cursor: string | null; + has_more: boolean; +}; + +/** Normalized task for list/card UI. */ +export type Task = { + id: string; + title: string; + priority: string; + department: string; + location: string; + description?: string; + dueTime?: string; + status: string; + isAssigned: boolean; +}; + +export type TasksFilterState = { + department?: string; + priority?: string; + status?: string; +}; + +export function mapBackendTask(t: BackendTask): Task { + const cap = (s: string) => + s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : s; + let due: string | undefined; + if (t.due_time) { + try { + due = new Date(t.due_time).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + } catch { + due = t.due_time; + } + } + return { + id: t.id, + title: t.title, + priority: cap(t.priority), + department: t.department?.trim() || "—", + location: t.location, + description: t.description ?? undefined, + dueTime: due, + status: t.status, + isAssigned: t.is_assigned, + }; +} diff --git a/clients/shared/src/api/client.ts b/clients/shared/src/api/client.ts index 2b97be39e..94c56f22a 100644 --- a/clients/shared/src/api/client.ts +++ b/clients/shared/src/api/client.ts @@ -18,15 +18,19 @@ export const createRequest = ( try { const token = await getToken(); + const headers: Record = { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + ...config.headers, + }; + const hotel = hotelId?.trim(); + if (hotel) { + headers["X-Hotel-ID"] = hotel; + } const response = await fetch(fullUrl, { method: config.method, - headers: { - "Content-Type": "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - "X-Hotel-ID": hotelId, - ...config.headers, - }, + headers, body: config.data ? JSON.stringify(config.data) : undefined, signal: config.signal, }); diff --git a/clients/shared/src/api/endpoints.ts b/clients/shared/src/api/endpoints.ts index 6abd58fdc..8be444b5a 100644 --- a/clients/shared/src/api/endpoints.ts +++ b/clients/shared/src/api/endpoints.ts @@ -1 +1,3 @@ -// API endpoint constants +export const API_ENDPOINTS = { + TASKS: "/tasks", +} as const; diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index 4037948e2..c4154de33 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -5,6 +5,8 @@ export type { Config } from "./api/config"; // config functions export { setConfig, getConfig } from "./api/config"; +export { API_ENDPOINTS } from "./api/endpoints"; +export { useAPIClient, getBaseUrl } from "./api/client"; // Generated Types - Models export { MakeRequestPriority } from "./api/generated/models"; @@ -65,6 +67,13 @@ export type { export { usePostRooms, useGetRoomsFloors } from "./api/generated/endpoints/rooms/rooms"; export { useGetGuestBookingsGroupSizes } from "./api/generated/endpoints/guest-bookings/guest-bookings"; +export { + usePostTasks, + usePatchTasksId, + usePostTasksIdClaim, + usePostTasksIdDrop, +} from "./api/generated/endpoints/tasks/tasks"; + export type { RoomWithOptionalGuestBooking, FilterRoomsRequest,