diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..675bd6f02 Binary files /dev/null and b/.DS_Store differ diff --git a/CLAUDE.md b/CLAUDE.md index fa8c164d3..8af9bb45b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,6 @@ # SelfServe +## Patterns ## Project Structure ``` diff --git a/backend/cmd/clerk/sync.go b/backend/cmd/clerk/sync.go index fecefbcad..5cc717fd6 100644 --- a/backend/cmd/clerk/sync.go +++ b/backend/cmd/clerk/sync.go @@ -27,7 +27,7 @@ func main() { usersRepo := repository.NewUsersRepository(repo.DB) path := "/users" - err = syncUsers(ctx, cfg.BaseURL+path, cfg.SecretKey, usersRepo) + err = syncUsers(ctx, cfg.BaseURL+path, cfg.SecretKey, usersRepo, cfg.DefaultHotelID) if err != nil { log.Fatal(err) } @@ -35,14 +35,14 @@ func main() { } func syncUsers(ctx context.Context, clerkBaseURL string, clerkSecret string, - usersRepo storage.UsersRepository) error { + usersRepo storage.UsersRepository, defaultHotelID string) error { users, err := clerk.FetchUsersFromClerk(clerkBaseURL, clerkSecret) if err != nil { return err } - transformed, err := clerk.ValidateAndReformatUserData(users) + transformed, err := clerk.ValidateAndReformatUserData(users, defaultHotelID) if err != nil { return err } diff --git a/backend/cmd/clerk/sync_test.go b/backend/cmd/clerk/sync_test.go index d978295fa..009fabe6b 100644 --- a/backend/cmd/clerk/sync_test.go +++ b/backend/cmd/clerk/sync_test.go @@ -12,6 +12,8 @@ import ( "github.com/stretchr/testify/require" ) +const syncTestDefaultHotelID = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + type mockUsersRepositorySync struct { bulkInsertFunc func(ctx context.Context, users []*models.CreateUser) error } @@ -62,7 +64,7 @@ func TestSyncUsers(t *testing.T) { }, } - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + err := syncUsers(context.Background(), server.URL, "test_secret", userMock, syncTestDefaultHotelID) require.NoError(t, err) assert.Len(t, capturedUsers, 2) @@ -90,7 +92,7 @@ func TestSyncUsers(t *testing.T) { }, } - err := syncUsers(context.Background(), server.URL, "bad_secret", userMock) + err := syncUsers(context.Background(), server.URL, "bad_secret", userMock, syncTestDefaultHotelID) require.Error(t, err) }) @@ -116,7 +118,7 @@ func TestSyncUsers(t *testing.T) { }, } - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + err := syncUsers(context.Background(), server.URL, "test_secret", userMock, syncTestDefaultHotelID) require.Error(t, err) }) @@ -142,7 +144,7 @@ func TestSyncUsers(t *testing.T) { }, } - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + err := syncUsers(context.Background(), server.URL, "test_secret", userMock, syncTestDefaultHotelID) require.Error(t, err) assert.Contains(t, err.Error(), "db connection failed") }) @@ -165,7 +167,7 @@ func TestSyncUsers(t *testing.T) { }, } - err := syncUsers(context.Background(), server.URL, "test_secret", userMock) + err := syncUsers(context.Background(), server.URL, "test_secret", userMock, syncTestDefaultHotelID) require.NoError(t, err) assert.True(t, insertCalled) }) diff --git a/backend/cmd/cli/main.go b/backend/cmd/cli/main.go index a922b86bc..94f580e79 100644 --- a/backend/cmd/cli/main.go +++ b/backend/cmd/cli/main.go @@ -93,7 +93,7 @@ func runSyncUsers(ctx context.Context, cfg config.Config, _ []string) error { return err } - transformed, err := clerk.ValidateAndReformatUserData(users) + transformed, err := clerk.ValidateAndReformatUserData(users, cfg.DefaultHotelID) if err != nil { return err } diff --git a/backend/config/application.go b/backend/config/application.go index a4a74ffd1..dea607e03 100644 --- a/backend/config/application.go +++ b/backend/config/application.go @@ -1,7 +1,8 @@ package config type Application struct { - Port string `env:"PORT" envDefault:"8080"` // Application port, fallback to 8080 - LogLevel string `env:"LOG_LEVEL" envDefault:"info"` // debug, info, warn, error - Host string `env:"HOST" envDefault:"localhost:8080"` // Public host used in Swagger UI + Port string `env:"PORT" envDefault:"8080"` // Application port, fallback to 8080 + LogLevel string `env:"LOG_LEVEL" envDefault:"info"` // debug, info, warn, error + Host string `env:"HOST" envDefault:"localhost:8080"` // Public host used in Swagger UI + DefaultHotelID string `env:"DEFAULT_HOTEL_ID" envDefault:"a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"` // TODO: Remove once we have clerk orgs to pull from for hotel IDs } diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 7f77ec810..c962c4655 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -126,6 +126,27 @@ definitions: example: "2024-01-02T00:00:00Z" type: string type: object + GuestBooking: + properties: + arrival_date: + example: "2024-01-02T00:00:00Z" + type: string + departure_date: + example: "2024-01-05T00:00:00Z" + type: string + guest: + $ref: '#/definitions/Guest' + hotel_id: + example: 521e8400-e458-41d4-a716-446655440000 + type: string + id: + example: f353ca91-4fc5-49f2-9b9e-304f83d11914 + type: string + room: + $ref: '#/definitions/Room' + status: + $ref: '#/definitions/github_com_generate_selfserve_internal_models.BookingStatus' + type: object GuestFilters: properties: cursor: @@ -332,6 +353,8 @@ definitions: enum: - pending - assigned + - in + - progress - completed example: assigned type: string @@ -402,6 +425,8 @@ definitions: enum: - pending - assigned + - in + - progress - completed example: assigned type: string @@ -409,6 +434,19 @@ definitions: example: 521ee400-e458-41d4-a716-446655440000 type: string type: object + Room: + properties: + floor: + type: integer + id: + type: string + room_number: + type: integer + room_status: + type: string + suite_type: + type: string + type: object RoomWithOptionalGuestBooking: properties: booking_status: @@ -419,6 +457,8 @@ definitions: items: $ref: '#/definitions/Guest' type: array + id: + type: string room_number: type: integer room_status: @@ -1041,11 +1081,6 @@ paths: description: Retrieves rooms with optional floor filters and cursor pagination, including any active guest bookings parameters: - - description: Hotel ID (UUID) - in: header - name: X-Hotel-ID - required: true - type: string - description: Filters and pagination in: body name: body @@ -1064,6 +1099,12 @@ paths: additionalProperties: type: string type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: @@ -1078,12 +1119,6 @@ paths: /rooms/floors: get: description: Retrieves all distinct floor numbers - parameters: - - description: Hotel ID (UUID) - in: header - name: X-Hotel-ID - required: true - type: string produces: - application/json responses: @@ -1093,12 +1128,26 @@ paths: items: type: integer type: array + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object "500": description: Internal Server Error schema: additionalProperties: type: string type: object + security: + - BearerAuth: [] summary: Get Floors tags: - rooms diff --git a/backend/go.mod b/backend/go.mod index a5020b802..740152200 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,15 +1,15 @@ module github.com/generate/selfserve -go 1.24.1 +go 1.25.0 require ( - github.com/aws/aws-sdk-go-v2 v1.41.1 - github.com/aws/aws-sdk-go-v2/config v1.32.7 - github.com/aws/aws-sdk-go-v2/credentials v1.19.7 - github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.14 + github.com/aws/aws-sdk-go-v2/credentials v1.19.14 + github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 github.com/clerk/clerk-sdk-go/v2 v2.5.1 - github.com/firebase/genkit/go v1.4.0 - github.com/go-playground/validator/v10 v10.30.1 + github.com/firebase/genkit/go v1.6.0 + github.com/go-playground/validator/v10 v10.30.2 github.com/gofiber/adaptor/v2 v2.2.1 github.com/opensearch-project/opensearch-go/v2 v2.3.0 github.com/redis/go-redis/v9 v9.17.3 @@ -21,29 +21,28 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect - github.com/aws/smithy-go v1.24.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -59,50 +58,49 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/goccy/go-yaml v1.17.1 // indirect - github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 // indirect + github.com/goccy/go-yaml v1.19.0 // indirect + github.com/google/dotprompt/go v0.0.0-20260227225921-0911cf9ecf0e // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/swaggo/files v1.0.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/sdk v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/goccy/go-json v0.10.5 - github.com/gofiber/fiber/v2 v2.52.10 + github.com/gofiber/fiber/v2 v2.52.12 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.8.0 + github.com/jackc/pgx/v5 v5.9.1 github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/svix/svix-webhooks v1.84.1 + github.com/mattn/go-runewidth v0.0.22 // indirect + github.com/svix/svix-webhooks v1.90.0 github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect - golang.org/x/sys v0.40.0 // indirect + golang.org/x/sys v0.42.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index c3068d0e1..35269998e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,55 +4,55 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go v1.44.263/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= -github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.18.25/go.mod h1:dZnYpD5wTW/dQF0rRNLVypB396zWCcPiBIvdvSWHEg4= -github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= -github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= +github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= github.com/aws/aws-sdk-go-v2/credentials v1.13.24/go.mod h1:jYPYi99wUOPIFi0rhiOvXeSEReVOzBqFNOX5bXYoG2o= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34/go.mod h1:Etz2dj6UHYuw+Xw830KfzCfWGMzqvUTCjUj5b76GVDc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= -github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0 h1:foqo/ocQ7WqKwy3FojGtZQJo0FR4vto9qnz9VaumbCo= +github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= github.com/aws/aws-sdk-go-v2/service/sso v1.12.10/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/sts v1.19.0/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -65,20 +65,18 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/clerk/clerk-sdk-go/v2 v2.5.1 h1:RsakGNW6ie83b9KIRtKzqDXBJ//cURy9SJUbGhrsIKg= github.com/clerk/clerk-sdk-go/v2 v2.5.1/go.mod h1:ncFmsPwmD5WpGCNW5bJve862j/HQfpkzsshXYV/quJ8= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/firebase/genkit/go v1.4.0 h1:CP1hNWk7z0hosyY53zMH6MFKFO1fMLtj58jGPllQo6I= -github.com/firebase/genkit/go v1.4.0/go.mod h1:HX6m7QOaGc3MDNr/DrpQZrzPLzxeuLxrkTvfFtCYlGw= -github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= -github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/firebase/genkit/go v1.6.0 h1:W+oTo/yndFkSe/imGEmsYdW5Qi5w2USG7obiXElAQzY= +github.com/firebase/genkit/go v1.6.0/go.mod h1:vu8ZAqNU6MU5qDza66bvqTtzJoUrqhO/+z5/6dtouJQ= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -119,18 +117,18 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= -github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= -github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= +github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= -github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= -github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254 h1:okN800+zMJOGHLJCgry+OGzhhtH6YrjQh1rluHmOacE= -github.com/google/dotprompt/go v0.0.0-20251014011017-8d056e027254/go.mod h1:k8cjJAQWc//ac/bMnzItyOFbfT01tgRTZGgxELCuxEQ= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/google/dotprompt/go v0.0.0-20260227225921-0911cf9ecf0e h1:pGKaGaqARcyjXNhQ6ZZ89FldngwgpYifR+13CSkH5pY= +github.com/google/dotprompt/go v0.0.0-20260227225921-0911cf9ecf0e/go.mod h1:mjF7S9XoK7vfdpnZa49V2nQEN0UJxnejJzveZ1hnYGA= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -143,8 +141,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= @@ -159,14 +157,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= +github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a/go.mod h1:Y6ghKH+ZijXn5d9E7qGGZBmjitx7iitZdQiIW97EpTU= github.com/opensearch-project/opensearch-go/v2 v2.3.0 h1:nQIEMr+A92CkhHrZgUhcfsrZjibvB3APXf2a1VwCmMQ= @@ -191,8 +189,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/svix/svix-webhooks v1.84.1 h1:N8L4TZAxpFLi+dT4T7Zweorwzqx1lYgGUhedbF3Nb6M= -github.com/svix/svix-webhooks v1.84.1/go.mod h1:BRbQWn/xdv6zSGULojHza0Yx+hDf+xUJ4s09t3HqJpI= +github.com/svix/svix-webhooks v1.90.0 h1:iV7zsDRfPGMTYz9S/dpHZbQSV7vmne2QXK6gLVzW9BA= +github.com/svix/svix-webhooks v1.90.0/go.mod h1:BRbQWn/xdv6zSGULojHza0Yx+hDf+xUJ4s09t3HqJpI= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= @@ -217,16 +215,18 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -234,12 +234,12 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -247,13 +247,13 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -264,8 +264,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -279,14 +279,14 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/backend/internal/errs/repository.go b/backend/internal/errs/repository.go index a929d602a..fd0edac3e 100644 --- a/backend/internal/errs/repository.go +++ b/backend/internal/errs/repository.go @@ -6,4 +6,15 @@ import "errors" var ( ErrNotFoundInDB = errors.New("not found in DB") ErrAlreadyExistsInDB = errors.New("already exists in DB") + + // Request insert / tasks (Postgres constraint or type errors) + ErrRequestUnknownHotel = errors.New("request insert: unknown hotel") + ErrRequestUnknownAssignee = errors.New("request insert: unknown assignee") + ErrRequestInvalidUserID = errors.New("request insert: user_id value rejected by database") + + // Users table cannot map a Clerk id (e.g. user_…) to requests.user_id — migrations incomplete. + ErrStaffUserIDNeedsDBMigration = errors.New("staff user id: database schema out of date for Clerk ids") + + // Task claim/drop: row exists but preconditions (pending/unassigned or assignee match) failed. + ErrTaskStateConflict = errors.New("task state conflict") ) diff --git a/backend/internal/handler/auth_context.go b/backend/internal/handler/auth_context.go new file mode 100644 index 000000000..a34a1dc8a --- /dev/null +++ b/backend/internal/handler/auth_context.go @@ -0,0 +1,41 @@ +package handler + +import ( + "context" + "errors" + "strings" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/gofiber/fiber/v2" +) + +const clerkUserIDLocalKey = "userId" + +// authUserLookup loads the authenticated staff user (Clerk id = users.id) for hotel scoping. +type authUserLookup interface { + FindUser(ctx context.Context, id string) (*models.User, error) +} + +// userIDAndHotelFromAuth resolves tenant and identity from Clerk JWT middleware (Locals userId). +func userIDAndHotelFromAuth(c *fiber.Ctx, users authUserLookup) (userID string, hotelID string, err error) { + raw := c.Locals(clerkUserIDLocalKey) + if raw == nil { + return "", "", errs.Unauthorized() + } + clerkID, ok := raw.(string) + if !ok || strings.TrimSpace(clerkID) == "" { + return "", "", errs.Unauthorized() + } + u, err := users.FindUser(c.Context(), clerkID) + if err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return "", "", errs.BadRequest("user is not registered; complete sign-in or run the Clerk user webhook once") + } + return "", "", err + } + if u.HotelID == nil || strings.TrimSpace(*u.HotelID) == "" { + return "", "", errs.BadRequest("user has no hotel association") + } + return u.ID, *u.HotelID, nil +} diff --git a/backend/internal/handler/clerk.go b/backend/internal/handler/clerk.go index 55991ae8f..95cafe977 100644 --- a/backend/internal/handler/clerk.go +++ b/backend/internal/handler/clerk.go @@ -14,6 +14,7 @@ import ( type ClerkWebHookHandler struct { UsersRepository storage.UsersRepository WebhookVerifier WebhookVerifier + DefaultHotelID string } type WebhookVerifier interface { @@ -24,8 +25,8 @@ func NewWebhookVerifier(cfg *config.Config) (WebhookVerifier, error) { return svix.NewWebhook(cfg.Clerk.WebhookSignature) } -func NewClerkWebHookHandler(userRepo storage.UsersRepository, WebhookVerifier WebhookVerifier) *ClerkWebHookHandler { - return &ClerkWebHookHandler{UsersRepository: userRepo, WebhookVerifier: WebhookVerifier} +func NewClerkWebHookHandler(userRepo storage.UsersRepository, WebhookVerifier WebhookVerifier, defaultHotelID string) *ClerkWebHookHandler { + return &ClerkWebHookHandler{UsersRepository: userRepo, WebhookVerifier: WebhookVerifier, DefaultHotelID: defaultHotelID} } func (h *ClerkWebHookHandler) CreateUser(c *fiber.Ctx) error { @@ -48,7 +49,7 @@ func (h *ClerkWebHookHandler) CreateUser(c *fiber.Ctx) error { return err } - res, err := h.UsersRepository.InsertUser(c.Context(), ReformatUserData(clerkUser)) + res, err := h.UsersRepository.InsertUser(c.Context(), ReformatUserData(clerkUser, h.DefaultHotelID)) if err != nil { return errs.InternalServerError() } diff --git a/backend/internal/handler/requests_test.go b/backend/internal/handler/requests_test.go index ffc5a3126..917d15b8c 100644 --- a/backend/internal/handler/requests_test.go +++ b/backend/internal/handler/requests_test.go @@ -22,6 +22,10 @@ type mockRequestRepository struct { findRequestFunc func(ctx context.Context, id string) (*models.Request, error) findRequestsFunc func(ctx context.Context) ([]models.Request, error) findRequestsByCursorFunc func(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) + findTasksFunc func(ctx context.Context, hotelID string, userID string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) + updateTaskStatusFunc func(ctx context.Context, hotelID, taskID, status string) error + claimTaskFunc func(ctx context.Context, hotelID, taskID, staffUserID string) error + dropTaskFunc func(ctx context.Context, hotelID, taskID, staffUserID string) error findRequestsByGuestIDFunc func(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) } @@ -41,6 +45,34 @@ func (m *mockRequestRepository) FindRequestsByStatusPaginated(ctx context.Contex return m.findRequestsByCursorFunc(ctx, cursor, status, hotelID, pageSize) } +func (m *mockRequestRepository) FindTasks(ctx context.Context, hotelID string, userID string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) { + if m.findTasksFunc == nil { + return nil, nil + } + return m.findTasksFunc(ctx, hotelID, userID, filter, cursor) +} + +func (m *mockRequestRepository) UpdateTaskStatus(ctx context.Context, hotelID, taskID, status string) error { + if m.updateTaskStatusFunc == nil { + return nil + } + return m.updateTaskStatusFunc(ctx, hotelID, taskID, status) +} + +func (m *mockRequestRepository) ClaimTask(ctx context.Context, hotelID, taskID, staffUserID string) error { + if m.claimTaskFunc == nil { + return nil + } + return m.claimTaskFunc(ctx, hotelID, taskID, staffUserID) +} + +func (m *mockRequestRepository) DropTask(ctx context.Context, hotelID, taskID, staffUserID string) error { + if m.dropTaskFunc == nil { + return nil + } + return m.dropTaskFunc(ctx, hotelID, taskID, staffUserID) +} + type mockLLMService struct { runGenerateRequestFunc func(ctx context.Context, input aiflows.GenerateRequestInput) (aiflows.GenerateRequestOutput, error) } @@ -50,6 +82,9 @@ func (m *mockLLMService) RunGenerateRequest(ctx context.Context, input aiflows.G } func (m *mockRequestRepository) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) { + if m.findRequestsByGuestIDFunc == nil { + return nil, nil + } return m.findRequestsByGuestIDFunc(ctx, guestID, hotelID, cursorID, cursorVersion, limit) } diff --git a/backend/internal/handler/rooms.go b/backend/internal/handler/rooms.go index f439d456e..0b4a342e6 100644 --- a/backend/internal/handler/rooms.go +++ b/backend/internal/handler/rooms.go @@ -17,11 +17,12 @@ type RoomsRepository interface { } type RoomsHandler struct { - repo RoomsRepository + repo RoomsRepository + users authUserLookup } -func NewRoomsHandler(repo RoomsRepository) *RoomsHandler { - return &RoomsHandler{repo: repo} +func NewRoomsHandler(repo RoomsRepository, users authUserLookup) *RoomsHandler { + return &RoomsHandler{repo: repo, users: users} } // FilterRooms godoc @@ -30,15 +31,15 @@ func NewRoomsHandler(repo RoomsRepository) *RoomsHandler { // @Tags rooms // @Accept json // @Produce json -// @Param X-Hotel-ID header string true "Hotel ID (UUID)" // @Param body body models.FilterRoomsRequest false "Filters and pagination" // @Success 200 {object} utils.CursorPage[models.RoomWithOptionalGuestBooking] // @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string // @Security BearerAuth // @Router /rooms [post] func (h *RoomsHandler) FilterRooms(c *fiber.Ctx) error { - hotelID, err := hotelIDFromHeader(c) + _, hotelID, err := userIDAndHotelFromAuth(c, h.users) if err != nil { return err } @@ -73,12 +74,14 @@ func (h *RoomsHandler) FilterRooms(c *fiber.Ctx) error { // @Description Retrieves all distinct floor numbers // @Tags rooms // @Produce json -// @Param X-Hotel-ID header string true "Hotel ID (UUID)" // @Success 200 {array} int +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string // @Failure 500 {object} map[string]string +// @Security BearerAuth // @Router /rooms/floors [get] func (h *RoomsHandler) GetFloors(c *fiber.Ctx) error { - hotelID, err := hotelIDFromHeader(c) + _, hotelID, err := userIDAndHotelFromAuth(c, h.users) if err != nil { return err } diff --git a/backend/internal/handler/rooms_test.go b/backend/internal/handler/rooms_test.go index c65358455..a85a563a5 100644 --- a/backend/internal/handler/rooms_test.go +++ b/backend/internal/handler/rooms_test.go @@ -31,6 +31,7 @@ func (m *mockRoomsRepository) FindAllFloors(ctx context.Context, hotelID string) var _ RoomsRepository = (*mockRoomsRepository)(nil) const testHotelID = "00000000-0000-0000-0000-000000000001" +const testClerkUserID = "user_test_clerk" func TestRoomsHandler_FilterRooms(t *testing.T) { t.Parallel() @@ -56,12 +57,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New() - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -102,12 +103,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New() - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{"floors":[2]}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -131,12 +132,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New() - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{"floors":[99]}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -164,12 +165,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New() - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{"limit":5}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -196,12 +197,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New() - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{"cursor":"200","limit":10}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -212,7 +213,7 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { assert.Equal(t, 200, capturedCursor) }) - t.Run("returns 400 when hotel_id header is missing", func(t *testing.T) { + t.Run("returns 401 when Authorization context is missing", func(t *testing.T) { t.Parallel() mock := &mockRoomsRepository{ @@ -222,7 +223,7 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{}`)) @@ -230,10 +231,10 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, 401, resp.StatusCode) }) - t.Run("returns 400 when hotel_id header is invalid", func(t *testing.T) { + t.Run("returns 400 when user has no hotel association", func(t *testing.T) { t.Parallel() mock := &mockRoomsRepository{ @@ -243,12 +244,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{noHotel: true}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, "not-a-uuid") resp, err := app.Test(req) require.NoError(t, err) @@ -267,12 +268,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -289,12 +290,12 @@ func TestRoomsHandler_FilterRooms(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Post("/rooms", h.FilterRooms) req := httptest.NewRequest("POST", "/rooms", strings.NewReader(`{`)) req.Header.Set("Content-Type", "application/json") - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -315,11 +316,11 @@ func TestRoomsHandler_GetFloors(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Get("/rooms/floors", h.GetFloors) req := httptest.NewRequest("GET", "/rooms/floors", nil) - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) @@ -330,7 +331,7 @@ func TestRoomsHandler_GetFloors(t *testing.T) { assert.Contains(t, string(body), `3`) }) - t.Run("returns 400 when hotel_id header is missing", func(t *testing.T) { + t.Run("returns 401 when clerk user context is missing", func(t *testing.T) { t.Parallel() mock := &mockRoomsRepository{ @@ -340,17 +341,17 @@ func TestRoomsHandler_GetFloors(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Get("/rooms/floors", h.GetFloors) req := httptest.NewRequest("GET", "/rooms/floors", nil) resp, err := app.Test(req) require.NoError(t, err) - assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, 401, resp.StatusCode) }) - t.Run("returns 400 when hotel_id header is invalid", func(t *testing.T) { + t.Run("returns 400 when user has no hotel association", func(t *testing.T) { t.Parallel() mock := &mockRoomsRepository{ @@ -360,11 +361,11 @@ func TestRoomsHandler_GetFloors(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{noHotel: true}) app.Get("/rooms/floors", h.GetFloors) req := httptest.NewRequest("GET", "/rooms/floors", nil) - req.Header.Set(hotelIDHeader, "not-a-uuid") resp, err := app.Test(req) require.NoError(t, err) @@ -381,11 +382,11 @@ func TestRoomsHandler_GetFloors(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := NewRoomsHandler(mock) + withTasksAuth(app, testClerkUserID) + h := NewRoomsHandler(mock, &mockTaskAuthLookup{hotelID: testHotelID}) app.Get("/rooms/floors", h.GetFloors) req := httptest.NewRequest("GET", "/rooms/floors", nil) - req.Header.Set(hotelIDHeader, testHotelID) resp, err := app.Test(req) require.NoError(t, err) diff --git a/backend/internal/handler/tasks.go b/backend/internal/handler/tasks.go new file mode 100644 index 000000000..fe0054716 --- /dev/null +++ b/backend/internal/handler/tasks.go @@ -0,0 +1,245 @@ +package handler + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + storage "github.com/generate/selfserve/internal/service/storage/postgres" + "github.com/generate/selfserve/internal/utils" + "github.com/generate/selfserve/internal/validation" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type TasksHandler struct { + repo storage.RequestsRepository + users authUserLookup +} + +func NewTasksHandler(repo storage.RequestsRepository, users authUserLookup) *TasksHandler { + return &TasksHandler{repo: repo, users: users} +} + +// validateCreateRequest applies the same rules as httpx.BindAndValidate for models.MakeRequest, +// since CreateTask builds the payload in code rather than from JSON. +func validateCreateRequest(req *models.Request) error { + if validation.Validate == nil { + return errs.InternalServerError() + } + if err := validation.Validate.Struct(req.MakeRequest); err != nil { + fieldErrors := validation.ToFieldErrors(err) + return errs.BadRequest(validation.DeterministicErrorString(fieldErrors)) + } + return nil +} + +// GetTasks returns a cursor page of tasks for the hotel, scoped by tab (my vs unassigned). +func (h *TasksHandler) GetTasks(c *fiber.Ctx) error { + authUserID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + filter := new(models.TaskFilter) + if err := c.QueryParser(filter); err != nil { + return errs.BadRequest("invalid query parameters") + } + + tab := models.TaskTab(strings.TrimSpace(filter.Tab)) + if tab != models.TaskTabMy && tab != models.TaskTabUnassigned { + return errs.BadRequest("tab must be my or unassigned") + } + + var userID string + if tab == models.TaskTabMy { + if strings.TrimSpace(authUserID) == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "message": "sign in required for my tasks", + }) + } + userID = authUserID + } + + cursorStr := strings.TrimSpace(c.Query("cursor")) + var taskCursor *models.TaskCursor + if cursorStr != "" { + decoded, decErr := utils.DecodeTaskCursor(cursorStr) + if decErr != nil || decoded == nil { + return errs.BadRequest("invalid cursor") + } + if decoded.Tab != tab { + return errs.BadRequest("cursor does not match tab") + } + taskCursor = decoded + } + + tasks, err := h.repo.FindTasks(c.Context(), hotelID, userID, filter, taskCursor) + if err != nil { + return errs.InternalServerError() + } + + limit := utils.ResolveLimit(filter.Limit) + page := utils.BuildCursorPage(tasks, limit, func(t *models.Task) string { + return t.Cursor + }) + + return c.JSON(page) +} + +// CreateTask creates a lightweight request row used as a staff task. +func (h *TasksHandler) CreateTask(c *fiber.Ctx) error { + _, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + + var body models.CreateTaskBody + if err := c.BodyParser(&body); err != nil { + return errs.InvalidJSON() + } + name := strings.TrimSpace(body.Name) + if name == "" { + return errs.BadRequest("name must not be empty") + } + + var userID *string + if body.AssignToMe { + raw := c.Locals(clerkUserIDLocalKey) + clerkID, ok := raw.(string) + clerkID = strings.TrimSpace(clerkID) + if !ok || clerkID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "message": "sign in required to assign a task to yourself", + }) + } + userID = &clerkID + } + + status := string(models.StatusPending) + if body.AssignToMe { + status = string(models.StatusAssigned) + } + + req := models.Request{ + ID: uuid.New().String(), + MakeRequest: models.MakeRequest{ + HotelID: hotelID, + Name: name, + RequestType: "adhoc", + Status: status, + Priority: "medium", + UserID: userID, + }, + } + + if err := validateCreateRequest(&req); err != nil { + return err + } + + res, err := h.repo.InsertRequest(c.Context(), &req) + if err != nil { + switch { + case errors.Is(err, errs.ErrRequestUnknownHotel): + return errs.BadRequest("hotel not found; check DEFAULT_HOTEL_ID and seed data") + case errors.Is(err, errs.ErrRequestUnknownAssignee): + return errs.BadRequest("assignee is not linked in the database") + case errors.Is(err, errs.ErrRequestInvalidUserID): + return errs.BadRequest("user id does not match the database type; run pending migrations (users/requests user_id)") + default: + slog.Error("InsertRequest from tasks", "error", err) + return errs.InternalServerError() + } + } + + return c.JSON(fiber.Map{"id": res.ID}) +} + +// PatchTask updates task (request) status for the hotel. +func (h *TasksHandler) PatchTask(c *fiber.Ctx) error { + _, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + id := strings.TrimSpace(c.Params("id")) + if !validUUID(id) { + return errs.BadRequest("invalid task id") + } + var body models.PatchTaskBody + if err := c.BodyParser(&body); err != nil { + return errs.InvalidJSON() + } + st := strings.TrimSpace(body.Status) + if !models.RequestStatus(st).IsValid() { + return errs.BadRequest("invalid status") + } + if err := h.repo.UpdateTaskStatus(c.Context(), hotelID, id, st); err != nil { + if errors.Is(err, errs.ErrNotFoundInDB) { + return errs.NotFound("task", "id", id) + } + slog.Error("UpdateTaskStatus", "error", err) + return errs.InternalServerError() + } + return c.SendStatus(http.StatusNoContent) +} + +// ClaimTask assigns an unassigned pending task to the current staff user. +func (h *TasksHandler) ClaimTask(c *fiber.Ctx) error { + userID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + id := strings.TrimSpace(c.Params("id")) + if !validUUID(id) { + return errs.BadRequest("invalid task id") + } + if strings.TrimSpace(userID) == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "message": "sign in required to claim a task", + }) + } + if err := h.repo.ClaimTask(c.Context(), hotelID, id, userID); err != nil { + switch { + case errors.Is(err, errs.ErrNotFoundInDB): + return errs.NotFound("task", "id", id) + case errors.Is(err, errs.ErrTaskStateConflict): + return errs.NewHTTPError(http.StatusConflict, errors.New("task cannot be claimed in its current state")) + default: + slog.Error("ClaimTask", "error", err) + return errs.InternalServerError() + } + } + return c.SendStatus(http.StatusNoContent) +} + +// DropTask returns a task to the unassigned pool if the current user holds it. +func (h *TasksHandler) DropTask(c *fiber.Ctx) error { + userID, hotelID, err := userIDAndHotelFromAuth(c, h.users) + if err != nil { + return err + } + id := strings.TrimSpace(c.Params("id")) + if !validUUID(id) { + return errs.BadRequest("invalid task id") + } + if strings.TrimSpace(userID) == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "message": "sign in required to drop a task", + }) + } + if err := h.repo.DropTask(c.Context(), hotelID, id, userID); err != nil { + switch { + case errors.Is(err, errs.ErrNotFoundInDB): + return errs.NotFound("task", "id", id) + case errors.Is(err, errs.ErrTaskStateConflict): + return errs.NewHTTPError(http.StatusConflict, errors.New("task cannot be dropped in its current state")) + default: + slog.Error("DropTask", "error", err) + return errs.InternalServerError() + } + } + return c.SendStatus(http.StatusNoContent) +} diff --git a/backend/internal/handler/tasks_test.go b/backend/internal/handler/tasks_test.go new file mode 100644 index 000000000..f2ea07a2c --- /dev/null +++ b/backend/internal/handler/tasks_test.go @@ -0,0 +1,330 @@ +package handler + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/generate/selfserve/internal/errs" + "github.com/generate/selfserve/internal/models" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockTaskAuthLookup struct { + hotelID string + findErr error + noHotel bool +} + +func (m *mockTaskAuthLookup) FindUser(ctx context.Context, id string) (*models.User, error) { + if m.findErr != nil { + return nil, m.findErr + } + if m.noHotel { + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: nil}}, nil + } + hid := m.hotelID + return &models.User{CreateUser: models.CreateUser{ID: id, HotelID: &hid}}, nil +} + +func withTasksAuth(app *fiber.App, userID string) { + app.Use(func(c *fiber.Ctx) error { + c.Locals("userId", userID) + return c.Next() + }) +} + +func TestTasksHandler_GetTasks(t *testing.T) { + t.Parallel() + + hotel := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + user := "user_clerk_test" + + t.Run("returns 200 for unassigned tab", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + findTasksFunc: func(ctx context.Context, hotelID string, uid string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) { + assert.Equal(t, hotel, hotelID) + assert.Equal(t, "", uid) + return []*models.Task{{ID: "t1", Title: "x", Status: "pending"}}, nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Get("/tasks", h.GetTasks) + + req := httptest.NewRequest(http.MethodGet, "/tasks?tab=unassigned", 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), "t1") + }) + + t.Run("returns 200 for my tab", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + findTasksFunc: func(ctx context.Context, hotelID string, uid string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) { + assert.Equal(t, hotel, hotelID) + assert.Equal(t, user, uid) + return []*models.Task{}, nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Get("/tasks", h.GetTasks) + + req := httptest.NewRequest(http.MethodGet, "/tasks?tab=my", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("returns 401 without auth context", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Get("/tasks", h.GetTasks) + + req := httptest.NewRequest(http.MethodGet, "/tasks?tab=unassigned", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) + }) +} + +func TestTasksHandler_CreateTask(t *testing.T) { + t.Parallel() + + hotel := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + user := "user_clerk_test" + + t.Run("returns id on success", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + makeRequestFunc: func(ctx context.Context, req *models.Request) (*models.Request, error) { + assert.NotEmpty(t, req.ID) + assert.Equal(t, hotel, req.HotelID) + return &models.Request{ID: req.ID, MakeRequest: req.MakeRequest}, nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks", h.CreateTask) + + body := bytes.NewBufferString(`{"name":"Test task","assign_to_me":false}`) + req := httptest.NewRequest(http.MethodPost, "/tasks", body) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + b, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(b), `"id"`) + }) + + t.Run("assign_to_me sets user id", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + makeRequestFunc: func(ctx context.Context, req *models.Request) (*models.Request, error) { + require.NotNil(t, req.UserID) + assert.Equal(t, user, *req.UserID) + return &models.Request{ID: req.ID, MakeRequest: req.MakeRequest}, nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks", h.CreateTask) + + body := bytes.NewBufferString(`{"name":"Mine","assign_to_me":true}`) + req := httptest.NewRequest(http.MethodPost, "/tasks", body) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) +} + +func TestTasksHandler_ClaimTask(t *testing.T) { + t.Parallel() + + hotel := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + user := "user_clerk_test" + taskID := "f0000001-0000-0000-0000-000000000001" + + t.Run("returns 204 on success", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + claimTaskFunc: func(ctx context.Context, hid, tid, staff string) error { + assert.Equal(t, hotel, hid) + assert.Equal(t, taskID, tid) + assert.Equal(t, user, staff) + return nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks/:id/claim", h.ClaimTask) + + req := httptest.NewRequest(http.MethodPost, "/tasks/"+taskID+"/claim", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 204, resp.StatusCode) + }) + + t.Run("returns 409 when claim conflicts", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + claimTaskFunc: func(ctx context.Context, hotelID, taskID, staffUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks/:id/claim", h.ClaimTask) + + req := httptest.NewRequest(http.MethodPost, "/tasks/"+taskID+"/claim", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) + + t.Run("returns 404 when task missing", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + claimTaskFunc: func(ctx context.Context, hotelID, taskID, staffUserID string) error { + return errs.ErrNotFoundInDB + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks/:id/claim", h.ClaimTask) + + req := httptest.NewRequest(http.MethodPost, "/tasks/"+taskID+"/claim", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) +} + +func TestTasksHandler_DropTask(t *testing.T) { + t.Parallel() + + hotel := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + user := "user_clerk_test" + taskID := "f0000001-0000-0000-0000-000000000001" + + t.Run("returns 204 on success", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + dropTaskFunc: func(ctx context.Context, hid, tid, staff string) error { + assert.Equal(t, user, staff) + return nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks/:id/drop", h.DropTask) + + req := httptest.NewRequest(http.MethodPost, "/tasks/"+taskID+"/drop", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 204, resp.StatusCode) + }) + + t.Run("returns 409 when drop conflicts", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + dropTaskFunc: func(ctx context.Context, hotelID, taskID, staffUserID string) error { + return errs.ErrTaskStateConflict + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Post("/tasks/:id/drop", h.DropTask) + + req := httptest.NewRequest(http.MethodPost, "/tasks/"+taskID+"/drop", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + }) +} + +func TestTasksHandler_PatchTask(t *testing.T) { + t.Parallel() + + hotel := "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" + user := "user_clerk_test" + taskID := "f0000001-0000-0000-0000-000000000001" + + t.Run("returns 204 on success", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hid, tid, status string) error { + assert.Equal(t, "completed", status) + return nil + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Patch("/tasks/:id", h.PatchTask) + + body := bytes.NewBufferString(`{"status":"completed"}`) + req := httptest.NewRequest(http.MethodPatch, "/tasks/"+taskID, body) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 204, resp.StatusCode) + }) + + t.Run("returns 400 for invalid status", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{} + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Patch("/tasks/:id", h.PatchTask) + + body := bytes.NewBufferString(`{"status":"bogus"}`) + req := httptest.NewRequest(http.MethodPatch, "/tasks/"+taskID, body) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + }) + + t.Run("returns 404 when task missing", func(t *testing.T) { + t.Parallel() + repo := &mockRequestRepository{ + updateTaskStatusFunc: func(ctx context.Context, hotelID, taskID, status string) error { + return errs.ErrNotFoundInDB + }, + } + app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) + withTasksAuth(app, user) + h := NewTasksHandler(repo, &mockTaskAuthLookup{hotelID: hotel}) + app.Patch("/tasks/:id", h.PatchTask) + + body := bytes.NewBufferString(`{"status":"completed"}`) + req := httptest.NewRequest(http.MethodPatch, "/tasks/"+taskID, body) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) + }) +} diff --git a/backend/internal/handler/utils.go b/backend/internal/handler/utils.go index fcb121641..73eed5f8f 100644 --- a/backend/internal/handler/utils.go +++ b/backend/internal/handler/utils.go @@ -6,28 +6,14 @@ import ( "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" - "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) -const hotelIDHeader = "X-Hotel-ID" - func validUUID(s string) bool { _, err := uuid.Parse(s) return err == nil } -func hotelIDFromHeader(c *fiber.Ctx) (string, error) { - hotelID := strings.TrimSpace(c.Get(hotelIDHeader)) - if hotelID == "" { - return "", errs.BadRequest("hotel_id header is required") - } - if !validUUID(hotelID) { - return "", errs.BadRequest("hotel_id header must be a valid uuid") - } - return hotelID, nil -} - func AggregateErrors(errors map[string]string) error { if len(errors) > 0 { var keys []string @@ -64,11 +50,13 @@ func ValidateCreateUserClerk(user *models.ClerkUser) error { return AggregateErrors(errors) } -func ReformatUserData(CreateUserRequest *models.ClerkUser) *models.CreateUser { +func ReformatUserData(CreateUserRequest *models.ClerkUser, defaultHotelID string) *models.CreateUser { + hid := strings.TrimSpace(defaultHotelID) result := &models.CreateUser{ FirstName: CreateUserRequest.FirstName, LastName: CreateUserRequest.LastName, ID: CreateUserRequest.ID, + HotelID: &hid, } if CreateUserRequest.HasImage { result.ProfilePicture = CreateUserRequest.ImageUrl diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go index 6c538f9d5..988d34385 100644 --- a/backend/internal/models/requests.go +++ b/backend/internal/models/requests.go @@ -33,7 +33,7 @@ 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:"oneof=pending assigned completed" example:"assigned"` + Status string `json:"status" validate:"oneof=pending assigned in progress 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"` diff --git a/backend/internal/models/tasks.go b/backend/internal/models/tasks.go new file mode 100644 index 000000000..283ab2db2 --- /dev/null +++ b/backend/internal/models/tasks.go @@ -0,0 +1,54 @@ +package models + +import "time" + +type TaskTab string + +const ( + TaskTabMy TaskTab = "my" + TaskTabUnassigned TaskTab = "unassigned" +) + +// TaskFilter is parsed from GET /tasks query parameters. +type TaskFilter struct { + Tab string `query:"tab"` + Limit int `query:"limit"` + Status string `query:"status"` + Department string `query:"department"` + Priority string `query:"priority"` +} + +// TaskCursor is the stable sort key for tasks pagination (tab-specific ordering + keyset). +// Version 2 cursors encode Tab, PriorityRank, DeptKey with CreatedAt and ID. +type TaskCursor struct { + Tab TaskTab + PriorityRank int + DeptKey string // LOWER(TRIM(department)) for unassigned ordering; empty for my tasks + CreatedAt time.Time + ID string +} + +// PatchTaskBody is PATCH /tasks/:id JSON body. +type PatchTaskBody struct { + Status string `json:"status"` +} + +// Task is the staff tasks list row derived from requests. +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Priority string `json:"priority"` + Department string `json:"department"` + Location string `json:"location"` + Description *string `json:"description,omitempty"` + DueTime *string `json:"due_time,omitempty"` + Status string `json:"status"` + IsAssigned bool `json:"is_assigned"` + Cursor string `json:"-"` +} + +// CreateTaskBody is the POST /tasks JSON body from the mobile app. +type CreateTaskBody struct { + Name string `json:"name"` + AssignToMe bool `json:"assign_to_me"` +} diff --git a/backend/internal/repository/requests.go b/backend/internal/repository/requests.go index 775a76fa4..29654be85 100644 --- a/backend/internal/repository/requests.go +++ b/backend/internal/repository/requests.go @@ -3,11 +3,15 @@ package repository import ( "context" "errors" + "strconv" + "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/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -42,6 +46,20 @@ func (r *RequestsRepository) InsertRequest(ctx context.Context, req *models.Requ req.ScheduledTime, req.Notes).Scan(&req.ID, &req.CreatedAt, &req.RequestVersion) if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23503": + switch pgErr.ConstraintName { + case "requests_hotel_id_fkey": + return nil, errs.ErrRequestUnknownHotel + case "requests_user_id_fkey": + return nil, errs.ErrRequestUnknownAssignee + } + case "22P02": + return nil, errs.ErrRequestInvalidUserID + } + } return nil, err } @@ -144,6 +162,173 @@ func (r *RequestsRepository) FindRequests(ctx context.Context) ([]models.Request return requests, nil } +const ( + taskPriorityRankSQL = `(CASE LOWER(TRIM(priority)) WHEN 'urgent' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'middle' THEN 2 WHEN 'low' THEN 1 ELSE 0 END)` + taskDeptKeySQL = `LOWER(TRIM(COALESCE(department, '')))` +) + +func taskPriorityRank(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 + } +} + +func taskDeptKey(department *string) string { + if department == nil { + return "" + } + return strings.ToLower(strings.TrimSpace(*department)) +} + +func (r *RequestsRepository) FindTasks(ctx context.Context, hotelID string, userID string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) { + limit := utils.ResolveLimit(filter.Limit) + params := []any{hotelID} + + var whereClauses []string + whereClauses = append(whereClauses, "hotel_id = $1") + + tab := models.TaskTab(filter.Tab) + if tab == models.TaskTabUnassigned { + whereClauses = append(whereClauses, "user_id IS NULL") + } else { + params = append(params, userID) + whereClauses = append(whereClauses, "user_id = $2") + } + + if filter.Status != "" { + params = append(params, filter.Status) + whereClauses = append(whereClauses, "status = $"+strconv.Itoa(len(params))) + } else if tab == models.TaskTabUnassigned { + params = append(params, string(models.StatusPending)) + whereClauses = append(whereClauses, "status = $"+strconv.Itoa(len(params))) + } else { + params = append(params, string(models.StatusAssigned), string(models.StatusInProgress)) + whereClauses = append(whereClauses, "status IN ($"+strconv.Itoa(len(params)-1)+", $"+strconv.Itoa(len(params))+")") + } + if filter.Department != "" { + params = append(params, filter.Department) + whereClauses = append(whereClauses, "LOWER(TRIM(department)) = LOWER(TRIM($"+strconv.Itoa(len(params))+"))") + } + if filter.Priority != "" { + params = append(params, filter.Priority) + whereClauses = append(whereClauses, "priority = $"+strconv.Itoa(len(params))) + } + + if cursor != nil { + if tab == models.TaskTabMy { + params = append(params, cursor.PriorityRank, cursor.CreatedAt, cursor.ID) + n := len(params) + whereClauses = append(whereClauses, + "("+taskPriorityRankSQL+" < $"+strconv.Itoa(n-2)+ + " OR ("+taskPriorityRankSQL+" = $"+strconv.Itoa(n-2)+ + " AND (created_at < $"+strconv.Itoa(n-1)+ + " OR (created_at = $"+strconv.Itoa(n-1)+" AND id < $"+strconv.Itoa(n)+"::uuid))))", + ) + } else { + params = append(params, cursor.DeptKey, cursor.PriorityRank, cursor.CreatedAt, cursor.ID) + n := len(params) + whereClauses = append(whereClauses, + "("+taskDeptKeySQL+" > $"+strconv.Itoa(n-3)+ + " OR ("+taskDeptKeySQL+" = $"+strconv.Itoa(n-3)+ + " AND ("+taskPriorityRankSQL+" < $"+strconv.Itoa(n-2)+ + " OR ("+taskPriorityRankSQL+" = $"+strconv.Itoa(n-2)+ + " AND (created_at < $"+strconv.Itoa(n-1)+ + " OR (created_at = $"+strconv.Itoa(n-1)+" AND id < $"+strconv.Itoa(n)+"::uuid))))))", + ) + } + } + + var orderBy string + if tab == models.TaskTabMy { + orderBy = taskPriorityRankSQL + " DESC, created_at DESC, id DESC" + } else { + orderBy = taskDeptKeySQL + " ASC, " + taskPriorityRankSQL + " DESC, created_at DESC, id DESC" + } + + params = append(params, limit+1) + limitParam := "$" + strconv.Itoa(len(params)) + query := ` + SELECT id, name, priority, department, room_id, location_display, description, scheduled_time, user_id, status, created_at + FROM requests + WHERE ` + strings.Join(whereClauses, " AND ") + ` + ORDER BY ` + orderBy + ` + LIMIT ` + limitParam + + rows, err := r.db.Query(ctx, query, params...) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []*models.Task + for rows.Next() { + task := models.Task{} + var roomID *string + var locationDisplay *string + var department *string + var scheduledTime *time.Time + var requestUserID *string + var createdAt time.Time + if err := rows.Scan( + &task.ID, + &task.Title, + &task.Priority, + &department, + &roomID, + &locationDisplay, + &task.Description, + &scheduledTime, + &requestUserID, + &task.Status, + &createdAt, + ); err != nil { + return nil, err + } + + if department == nil || strings.TrimSpace(*department) == "" { + task.Department = "Unknown" + } else { + task.Department = strings.TrimSpace(*department) + } + + task.Location = "Room unavailable" + if locationDisplay != nil && strings.TrimSpace(*locationDisplay) != "" { + task.Location = strings.TrimSpace(*locationDisplay) + } else if roomID != nil && strings.TrimSpace(*roomID) != "" { + task.Location = "Room " + strings.TrimSpace(*roomID) + } + + if scheduledTime != nil { + formatted := scheduledTime.Format(time.RFC3339) + task.DueTime = &formatted + } + task.IsAssigned = requestUserID != nil + encodedCursor, err := utils.EncodeTaskCursor(models.TaskCursor{ + Tab: tab, + PriorityRank: taskPriorityRank(task.Priority), + DeptKey: taskDeptKey(department), + CreatedAt: createdAt, + ID: task.ID, + }) + if err != nil { + return nil, err + } + task.Cursor = encodedCursor + tasks = append(tasks, &task) + } + + return tasks, rows.Err() +} + func (r *RequestsRepository) FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) { rows, err := r.db.Query(ctx, ` WITH latest AS ( @@ -183,3 +368,67 @@ func (r *RequestsRepository) FindRequestsByGuestID(ctx context.Context, guestID, return requests, rows.Err() } + +func (r *RequestsRepository) UpdateTaskStatus(ctx context.Context, hotelID, taskID, status string) error { + cmd, err := r.db.Exec(ctx, ` + UPDATE requests + SET status = $1 + WHERE id = $2::uuid AND hotel_id = $3::uuid + `, status, taskID, hotelID) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return errs.ErrNotFoundInDB + } + return nil +} + +func (r *RequestsRepository) ClaimTask(ctx context.Context, hotelID, taskID, staffUserID string) error { + cmd, err := r.db.Exec(ctx, ` + UPDATE requests + SET user_id = $1, status = $2 + WHERE id = $3::uuid AND hotel_id = $4::uuid + AND user_id IS NULL AND status = $5 + `, staffUserID, string(models.StatusAssigned), taskID, hotelID, string(models.StatusPending)) + if err != nil { + return err + } + if cmd.RowsAffected() > 0 { + return nil + } + var dummy string + err = r.db.QueryRow(ctx, `SELECT id::text FROM requests WHERE id = $1::uuid AND hotel_id = $2::uuid`, taskID, hotelID).Scan(&dummy) + if errors.Is(err, pgx.ErrNoRows) { + return errs.ErrNotFoundInDB + } + if err != nil { + return err + } + return errs.ErrTaskStateConflict +} + +func (r *RequestsRepository) DropTask(ctx context.Context, hotelID, taskID, staffUserID string) error { + cmd, err := r.db.Exec(ctx, ` + UPDATE requests + SET user_id = NULL, status = $1 + WHERE id = $2::uuid AND hotel_id = $3::uuid + AND user_id = $4 + AND status IN ($5, $6) + `, string(models.StatusPending), taskID, hotelID, staffUserID, string(models.StatusAssigned), string(models.StatusInProgress)) + if err != nil { + return err + } + if cmd.RowsAffected() > 0 { + return nil + } + var dummy string + err = r.db.QueryRow(ctx, `SELECT id::text FROM requests WHERE id = $1::uuid AND hotel_id = $2::uuid`, taskID, hotelID).Scan(&dummy) + if errors.Is(err, pgx.ErrNoRows) { + return errs.ErrNotFoundInDB + } + if err != nil { + return err + } + return errs.ErrTaskStateConflict +} diff --git a/backend/internal/repository/users.go b/backend/internal/repository/users.go index b6b5292b4..98d89857a 100644 --- a/backend/internal/repository/users.go +++ b/backend/internal/repository/users.go @@ -3,10 +3,12 @@ package repository import ( "context" "errors" + "strings" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) @@ -18,6 +20,50 @@ func NewUsersRepository(db *pgxpool.Pool) *UsersRepository { return &UsersRepository{db: db} } +// ResolveStaffUserIDForRequests maps X-Dev-User-Id / Clerk ids to the value stored in requests.user_id. +// Supports: (1) post-migration schema where users.id is the Clerk id (text), and (2) legacy schema with +// uuid users.id plus users.clerk_id. +// TODO(production): Prefer a verified auth principal from middleware instead of +// accepting raw user identity from request headers. +func (r *UsersRepository) ResolveStaffUserIDForRequests(ctx context.Context, header string) (string, error) { + header = strings.TrimSpace(header) + if header == "" { + return "", nil + } + + var id string + var idLookupWasInvalidUUID bool + err := r.db.QueryRow(ctx, `SELECT id::text FROM users WHERE id = $1`, header).Scan(&id) + if err == nil { + return id, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "22P02" { + idLookupWasInvalidUUID = true + err = pgx.ErrNoRows + } else { + return "", err + } + } + + err = r.db.QueryRow(ctx, `SELECT id::text FROM users WHERE clerk_id = $1`, header).Scan(&id) + if err == nil { + return id, nil + } + if errors.Is(err, pgx.ErrNoRows) { + return "", errs.ErrNotFoundInDB + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "42703" && idLookupWasInvalidUUID { + return "", errs.ErrStaffUserIDNeedsDBMigration + } + if errors.As(err, &pgErr) && pgErr.Code == "42703" { + return "", errs.ErrNotFoundInDB + } + return "", err +} + func (r *UsersRepository) FindUser(ctx context.Context, id string) (*models.User, error) { row := r.db.QueryRow(ctx, ` SELECT id, first_name, last_name, hotel_id, employee_id, profile_picture, role, department, timezone, phone_number, primary_email, created_at, updated_at FROM users where id = $1 diff --git a/backend/internal/service/clerk/middleware.go b/backend/internal/service/clerk/middleware.go index 499cc72cb..8030e1464 100644 --- a/backend/internal/service/clerk/middleware.go +++ b/backend/internal/service/clerk/middleware.go @@ -2,6 +2,7 @@ package clerk import ( "context" + "log" "strings" jwt "github.com/clerk/clerk-sdk-go/v2/jwt" @@ -13,21 +14,20 @@ func NewAuthMiddleware(verifier JWTVerifier) fiber.Handler { return func(c *fiber.Ctx) error { authHeader := c.Get("Authorization") if !strings.Contains(authHeader, "Bearer ") { + log.Printf("[AUTH] No Bearer token. Header: %q", authHeader) return errs.Unauthorized() } token := strings.TrimPrefix(authHeader, "Bearer ") clerkId, err := verifier.Verify(c.Context(), token) - if err != nil { + log.Printf("[AUTH] JWT verify failed: %v", err) return errs.Unauthorized() } + log.Printf("[AUTH] Verified user: %s", clerkId) c.Locals("userId", clerkId) - if err := c.Next(); err != nil { - return err - } - return nil + return c.Next() } } diff --git a/backend/internal/service/clerk/sync-users.go b/backend/internal/service/clerk/sync-users.go index f7f7f8ee9..418dde58c 100644 --- a/backend/internal/service/clerk/sync-users.go +++ b/backend/internal/service/clerk/sync-users.go @@ -8,13 +8,13 @@ import ( "github.com/generate/selfserve/internal/models" ) -func ValidateAndReformatUserData(users []models.ClerkUser) ([]*models.CreateUser, error) { +func ValidateAndReformatUserData(users []models.ClerkUser, defaultHotelID string) ([]*models.CreateUser, error) { var reformatedUsers []*models.CreateUser for _, user := range users { if err := handler.ValidateCreateUserClerk(&user); err != nil { return nil, err } - reformatedUsers = append(reformatedUsers, handler.ReformatUserData(&user)) + reformatedUsers = append(reformatedUsers, handler.ReformatUserData(&user, defaultHotelID)) } return reformatedUsers, nil } diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 7e176f76a..8d59ab523 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -14,7 +14,6 @@ import ( "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/handler" "github.com/generate/selfserve/internal/repository" - "github.com/generate/selfserve/internal/service/clerk" notificationssvc "github.com/generate/selfserve/internal/service/notifications" "github.com/generate/selfserve/internal/storage/redis" @@ -141,46 +140,48 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo reqsHandler := handler.NewRequestsHandler(repository.NewRequestsRepo(repo.DB), genkitInstance, notifService) hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB)) s3Handler := handler.NewS3Handler(s3Store) - roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) + roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB), usersRepo) + tasksHandler := handler.NewTasksHandler(repository.NewRequestsRepo(repo.DB), usersRepo) guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB)) clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg) if err != nil { return err } - clerkWebhookHandler := handler.NewClerkWebHookHandler(usersRepo, clerkWhSignatureVerifier) + clerkWebhookHandler := handler.NewClerkWebHookHandler(usersRepo, clerkWhSignatureVerifier, cfg.DefaultHotelID) // API v1 routes api := app.Group("/api/v1") - // clerk webhook route + // Clerk webhook (Svix-signed; must not require JWT) api.Route("/clerk", func(r fiber.Router) { r.Post("/user", clerkWebhookHandler.CreateUser) }) verifier := clerk.NewClerkJWTVerifier() - app.Use(clerk.NewAuthMiddleware(verifier)) + protected := api.Group("/") + protected.Use(clerk.NewAuthMiddleware(verifier)) // Hello routes - api.Route("/hello", func(r fiber.Router) { + protected.Route("/hello", func(r fiber.Router) { r.Get("/", helloHandler.GetHello) r.Get("/:name", helloHandler.GetHelloName) }) // Dev routes - api.Route("/devs", func(r fiber.Router) { + protected.Route("/devs", func(r fiber.Router) { r.Get("/:name", devsHandler.GetMember) }) // users routes - api.Route("/users", func(r fiber.Router) { + protected.Route("/users", func(r fiber.Router) { r.Get("/:id", usersHandler.GetUserByID) r.Post("/", usersHandler.CreateUser) r.Put("/:id", usersHandler.UpdateUser) }) // Guest Routes - api.Route("/guests", func(r fiber.Router) { + protected.Route("/guests", func(r fiber.Router) { r.Post("/", guestsHandler.CreateGuest) r.Get("/:id", guestsHandler.GetGuest) r.Put("/:id", guestsHandler.UpdateGuest) @@ -189,7 +190,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo }) // Request routes - api.Route("/request", func(r fiber.Router) { + protected.Route("/request", func(r fiber.Router) { r.Post("/", reqsHandler.CreateRequest) r.Post("/generate", reqsHandler.GenerateRequest) r.Put("/:id", reqsHandler.UpdateRequest) @@ -199,24 +200,33 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo }) // Hotel routes - api.Route("/hotels", func(r fiber.Router) { + protected.Route("/hotels", func(r fiber.Router) { r.Get("/:id", hotelsHandler.GetHotelByID) r.Post("/", hotelsHandler.CreateHotel) }) // rooms routes - api.Route("/rooms", func(r fiber.Router) { + protected.Route("/rooms", func(r fiber.Router) { r.Post("/", roomsHandler.FilterRooms) r.Get("/floors", roomsHandler.GetFloors) }) + // tasks routes + protected.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) + }) + // guest booking routes api.Route("/guest_bookings", func(r fiber.Router) { r.Get("/group_sizes", guestBookingsHandler.GetGroupSizeOptions) }) // s3 routes - api.Route("/s3", func(r fiber.Router) { + protected.Route("/s3", func(r fiber.Router) { r.Get("/presigned-url/:key", s3Handler.GeneratePresignedURL) }) @@ -256,7 +266,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 85a13e5d8..0b20d3eca 100644 --- a/backend/internal/service/storage/postgres/repo_types.go +++ b/backend/internal/service/storage/postgres/repo_types.go @@ -42,6 +42,11 @@ type RequestsRepository interface { FindRequest(ctx context.Context, id string) (*models.Request, error) FindRequests(ctx context.Context) ([]models.Request, error) FindRequestsByStatusPaginated(ctx context.Context, cursor string, status string, hotelID string, pageSize int) ([]*models.Request, string, error) + + FindTasks(ctx context.Context, hotelID string, userID string, filter *models.TaskFilter, cursor *models.TaskCursor) ([]*models.Task, error) + UpdateTaskStatus(ctx context.Context, hotelID, taskID, status string) error + ClaimTask(ctx context.Context, hotelID, taskID, staffUserID string) error + DropTask(ctx context.Context, hotelID, taskID, staffUserID string) error FindRequestsByGuestID(ctx context.Context, guestID, hotelID, cursorID string, cursorVersion time.Time, limit int) ([]*models.GuestRequest, error) } diff --git a/backend/internal/tests/clerk_test.go b/backend/internal/tests/clerk_test.go index 120510958..1e0f280a1 100644 --- a/backend/internal/tests/clerk_test.go +++ b/backend/internal/tests/clerk_test.go @@ -66,7 +66,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) @@ -104,7 +104,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) @@ -138,7 +138,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(`{invalid`)) @@ -169,7 +169,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) invalidPayload := `{ @@ -209,7 +209,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New(fiber.Config{ErrorHandler: errs.ErrorHandler}) - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) @@ -247,7 +247,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) req := httptest.NewRequest("POST", "/webhook", bytes.NewBufferString(validPayload)) @@ -287,7 +287,7 @@ func TestClerkHandler_CreateUser(t *testing.T) { } app := fiber.New() - h := handler.NewClerkWebHookHandler(userMock, webhookMock) + h := handler.NewClerkWebHookHandler(userMock, webhookMock, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11") app.Post("/webhook", h.CreateUser) payloadWithImage := `{ diff --git a/backend/internal/utils/task_cursor.go b/backend/internal/utils/task_cursor.go new file mode 100644 index 000000000..6658e0941 --- /dev/null +++ b/backend/internal/utils/task_cursor.go @@ -0,0 +1,72 @@ +package utils + +import ( + "encoding/base64" + "encoding/json" + "errors" + "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"` + T string `json:"t"` + ID string `json:"id"` +} + +func EncodeTaskCursor(c models.TaskCursor) (string, error) { + p := taskCursorPayload{ + V: taskCursorVersion, + Tab: string(c.Tab), + Pr: c.PriorityRank, + Dk: c.DeptKey, + T: c.CreatedAt.UTC().Format(time.RFC3339Nano), + ID: c.ID, + } + b, err := json.Marshal(p) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func DecodeTaskCursor(s string) (*models.TaskCursor, error) { + if s == "" { + return nil, nil + } + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return nil, err + } + var p taskCursorPayload + if err := json.Unmarshal(raw, &p); err != nil { + return nil, err + } + if p.V != taskCursorVersion { + return nil, errors.New("unsupported task cursor version") + } + tab := models.TaskTab(p.Tab) + if tab != models.TaskTabMy && tab != models.TaskTabUnassigned { + return nil, errors.New("invalid task cursor tab") + } + t, err := time.Parse(time.RFC3339Nano, p.T) + if err != nil { + return nil, err + } + if p.ID == "" { + return nil, errors.New("invalid task cursor id") + } + return &models.TaskCursor{ + Tab: tab, + PriorityRank: p.Pr, + DeptKey: p.Dk, + CreatedAt: t, + ID: p.ID, + }, nil +} diff --git a/backend/supabase/migrations/20260402000000_add_location_display_to_requests.sql b/backend/supabase/migrations/20260402000000_add_location_display_to_requests.sql new file mode 100644 index 000000000..554f88af6 --- /dev/null +++ b/backend/supabase/migrations/20260402000000_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/.env.sample b/clients/mobile/.env.sample index 5eee17e0e..412dd0a2e 100644 --- a/clients/mobile/.env.sample +++ b/clients/mobile/.env.sample @@ -5,4 +5,4 @@ # To manage: https://dashboard.doppler.com/ EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_CLERK_PUBLISHABLE_KEY -EXPO_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1 \ No newline at end of file +EXPO_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1 diff --git a/clients/mobile/app/(tabs)/tasks.tsx b/clients/mobile/app/(tabs)/tasks.tsx index b078eb537..105e4df77 100644 --- a/clients/mobile/app/(tabs)/tasks.tsx +++ b/clients/mobile/app/(tabs)/tasks.tsx @@ -1,54 +1,389 @@ -import { useState } from "react"; -import { View } from "react-native"; +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Alert, Text, View } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { ActiveFilterChips } from "@/components/tasks/active-filter-chips"; +import { + ActiveFilterChips, + type TaskFilterChip, +} 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"; +import { SearchBar } from "@/components/ui/search-bar"; +import { + TAB, + type TabName, + TASK_ASSIGNMENT_STATE, + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + type TaskViewMode, +} from "@/constants/tasks"; +import { useTasksFeed } from "@/hooks/use-tasks-feed"; +import { useAPIClient } from "@shared/api/client"; +import { API_ENDPOINTS } from "@shared/api/endpoints"; +import { ApiError } from "@shared"; +import type { Task, TasksFilterState } from "@/types/tasks"; const tabConfigs: Record< TabName, { - tasks: typeof myTasks; variant: (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; - showFilters: boolean; } > = { - [TAB.MY_TASKS]: { - tasks: myTasks, - variant: TASK_ASSIGNMENT_STATE.ASSIGNED, - showFilters: false, - }, - [TAB.UNASSIGNED]: { - tasks: unassignedTasks, - variant: TASK_ASSIGNMENT_STATE.UNASSIGNED, - showFilters: true, - }, + [TAB.MY_TASKS]: { variant: TASK_ASSIGNMENT_STATE.ASSIGNED }, + [TAB.UNASSIGNED]: { variant: TASK_ASSIGNMENT_STATE.UNASSIGNED }, }; +function labelForDepartment(value: string) { + return TASK_FILTER_DEPARTMENTS.find((d) => d.value === value)?.label ?? value; +} + +function labelForPriority(value: string) { + return TASK_FILTER_PRIORITIES.find((p) => p.value === value)?.label ?? value; +} + +function labelForStatus(value: string, tab: TabName) { + const list = + tab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED; + return list.find((s) => s.value === value)?.label ?? value; +} + +function buildChips(filters: TasksFilterState, tab: TabName): TaskFilterChip[] { + const chips: TaskFilterChip[] = []; + if (filters.department) { + chips.push({ + id: `department:${filters.department}`, + label: `Department: ${labelForDepartment(filters.department)}`, + }); + } + if (filters.priority) { + chips.push({ + id: `priority:${filters.priority}`, + label: `Priority: ${labelForPriority(filters.priority)}`, + }); + } + if (filters.status) { + chips.push({ + id: `status:${filters.status}`, + label: `Status: ${labelForStatus(filters.status, tab)}`, + }); + } + return chips; +} + +function removeChipId( + filters: TasksFilterState, + chipId: string, +): TasksFilterState { + const next = { ...filters }; + if (chipId.startsWith("department:")) next.department = undefined; + if (chipId.startsWith("priority:")) next.priority = undefined; + if (chipId.startsWith("status:")) next.status = undefined; + return next; +} + export default function TasksScreen() { + const api = useAPIClient(); + const queryClient = useQueryClient(); + const router = useRouter(); + + const filterSheetRef = useRef(null); + const detailSheetRef = useRef(null); + const [activeTab, setActiveTab] = useState(TAB.MY_TASKS); + + const [myTasksFilters, setMyTasksFilters] = useState({}); + const [unassignedFilters, setUnassignedFilters] = useState( + {}, + ); + const [myViewMode, setMyViewMode] = useState("default"); + const [unassignedViewMode, setUnassignedViewMode] = + useState("default"); + + const [sheetDraft, setSheetDraft] = useState({}); + + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const [selectedTask, setSelectedTask] = useState(null); + + const [completionOpen, setCompletionOpen] = useState(false); + const [completedTitle, setCompletedTitle] = useState(""); + const [managerNote, setManagerNote] = useState(""); + + const activeFilters = + activeTab === TAB.MY_TASKS ? myTasksFilters : unassignedFilters; + const setActiveFilters = + activeTab === TAB.MY_TASKS ? setMyTasksFilters : setUnassignedFilters; + + const activeViewMode = + activeTab === TAB.MY_TASKS ? myViewMode : unassignedViewMode; + const setActiveViewMode = + activeTab === TAB.MY_TASKS ? setMyViewMode : setUnassignedViewMode; + + const { + data, + error, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTasksFeed(activeTab, activeFilters); + + const tasks = data?.pages.flatMap((page) => page.items) ?? []; + + const filteredTasks = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return tasks; + return tasks.filter((t) => { + const blob = [t.title, t.description ?? "", t.department, t.location] + .join(" ") + .toLowerCase(); + return blob.includes(q); + }); + }, [tasks, searchQuery]); + + const chips = useMemo( + () => buildChips(activeFilters, activeTab), + [activeFilters, activeTab], + ); + + const patchStatus = useMutation({ + mutationFn: async ({ id, status }: { id: string; status: string }) => { + await api.patch(API_ENDPOINTS.task(id), { status }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks-feed"] }); + }, + }); + + const claimTask = useMutation({ + mutationFn: async (id: string) => { + await api.post(API_ENDPOINTS.taskClaim(id), {}); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks-feed"] }); + detailSheetRef.current?.dismiss(); + }, + }); + + const dropTask = useMutation({ + mutationFn: async (id: string) => { + await api.post(API_ENDPOINTS.taskDrop(id), {}); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["tasks-feed"] }); + detailSheetRef.current?.dismiss(); + }, + }); + + const openFilters = useCallback(() => { + setSheetDraft(activeFilters); + filterSheetRef.current?.present(); + }, [activeFilters]); + + const applyFilters = useCallback(() => { + setActiveFilters(sheetDraft); + filterSheetRef.current?.dismiss(); + }, [sheetDraft, setActiveFilters]); + + const openDetail = useCallback((task: Task) => { + setSelectedTask(task); + requestAnimationFrame(() => detailSheetRef.current?.present()); + }, []); + + const handleStart = useCallback( + (task: Task) => { + patchStatus.mutate( + { id: task.id, status: "in progress" }, + { + onSuccess: () => detailSheetRef.current?.dismiss(), + onError: (e) => { + const msg = + e instanceof ApiError ? e.message : "Could not start task"; + Alert.alert("Error", msg); + }, + }, + ); + }, + [patchStatus], + ); + + const handleMarkDone = useCallback( + (task: Task) => { + patchStatus.mutate( + { id: task.id, status: "completed" }, + { + onSuccess: () => { + detailSheetRef.current?.dismiss(); + setCompletedTitle(task.title); + setManagerNote(""); + setCompletionOpen(true); + }, + onError: (e) => { + const msg = + e instanceof ApiError ? e.message : "Could not complete task"; + Alert.alert("Error", msg); + }, + }, + ); + }, + [patchStatus], + ); + + const handleClaim = useCallback( + (task: Task) => { + claimTask.mutate(task.id, { + onError: (e) => { + const msg = + e instanceof ApiError ? e.message : "Could not claim task"; + Alert.alert("Error", msg); + }, + }); + }, + [claimTask], + ); + + const handleDrop = useCallback( + (task: Task) => { + Alert.alert( + "Drop task?", + "This returns the task to the unassigned list.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Drop", + style: "destructive", + onPress: () => + dropTask.mutate(task.id, { + onError: (e) => { + const msg = + e instanceof ApiError ? e.message : "Could not drop task"; + Alert.alert("Error", msg); + }, + }), + }, + ], + ); + }, + [dropTask], + ); + + const errorMessage = (() => { + if (!isError) return ""; + if (error instanceof ApiError) { + if (error.status === 401) { + return "Sign in with Clerk to load tasks."; + } + if (error.status === 400) return error.message; + if (error.status === 0) + return "Cannot reach the server. Check EXPO_PUBLIC_API_BASE_URL (use your machine IP on device)."; + if (__DEV__) return `${error.message} (HTTP ${error.status})`; + } + return "We could not load tasks right now."; + })(); + const currentTab = tabConfigs[activeTab]; return ( - + setSearchOpen((o) => !o)} + onOpenNotifications={() => + (router.push as (href: string) => void)("/notifications") + } + searchActive={searchOpen} + /> - {currentTab.showFilters && ( - {}} - onClearAll={() => {}} - /> - )} + {searchOpen ? ( + + + + ) : null} + setActiveFilters((f) => removeChipId(f, id))} + onClearAll={() => setActiveFilters({})} + /> + - + {isLoading ? ( + + + + ) : isError ? ( + + {errorMessage} + + ) : ( + { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }} + isFetchingNextPage={isFetchingNextPage} + onPressTask={openDetail} + onStart={handleStart} + onClaim={handleClaim} + onMarkDone={handleMarkDone} + /> + )} + + + + setSelectedTask(null)} + onStart={handleStart} + onClaim={handleClaim} + onMarkDone={handleMarkDone} + onDrop={handleDrop} + onFindCover={() => + Alert.alert( + "Find a Cover", + "Reassigning to another teammate will be available when the backend supports it.", + ) + } + /> + + setCompletionOpen(false)} + /> ); } diff --git a/clients/mobile/app/_layout.tsx b/clients/mobile/app/_layout.tsx index 44af1c301..afaf3c45e 100644 --- a/clients/mobile/app/_layout.tsx +++ b/clients/mobile/app/_layout.tsx @@ -4,6 +4,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "react-native-reanimated"; import "../global.css"; +import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { DarkTheme, DefaultTheme, @@ -14,6 +15,8 @@ import { tokenCache } from "@clerk/clerk-expo/token-cache"; import { ClerkProvider, ClerkLoaded, useAuth } from "@clerk/clerk-expo"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { setConfig } from "@shared"; +import { useEffect } from "react"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; // Client explicity created outside component to avoid recreation const queryClient = new QueryClient({ @@ -33,10 +36,16 @@ export const unstable_settings = { // Component to configure auth provider and the api base url function AppConfigurator() { const { getToken } = useAuth(); - setConfig({ - API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL ?? "", - getToken, - }); + useEffect(() => { + setConfig({ + API_BASE_URL: + process.env.EXPO_PUBLIC_API_BASE_URL ?? + process.env.EXPO_PUBLIC_API_URL ?? + "", + getToken, + }); + }, [getToken]); + return null; } @@ -52,18 +61,25 @@ export default function RootLayout() { - - - - - - - + + + + + + + + + + + diff --git a/clients/mobile/app/notifications.tsx b/clients/mobile/app/notifications.tsx new file mode 100644 index 000000000..8094d7a1a --- /dev/null +++ b/clients/mobile/app/notifications.tsx @@ -0,0 +1,21 @@ +import { Stack } from "expo-router"; +import { Text, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export default function NotificationsScreen() { + return ( + <> + + + + + Notifications + + + WIll be implemented soon. + + + + + ); +} diff --git a/clients/mobile/components/tasks/active-filter-chips.tsx b/clients/mobile/components/tasks/active-filter-chips.tsx index c6b8ee592..8b097e60f 100644 --- a/clients/mobile/components/tasks/active-filter-chips.tsx +++ b/clients/mobile/components/tasks/active-filter-chips.tsx @@ -1,33 +1,49 @@ import Feather from "@expo/vector-icons/Feather"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; + +export type TaskFilterChip = { + id: string; + label: string; +}; interface ActiveFilterChipsProps { - filters: { label: string; value: string }[]; - onRemoveFilter: (value: string) => void; + chips: TaskFilterChip[]; + onRemove: (id: string) => void; onClearAll: () => void; } export function ActiveFilterChips({ - filters, - onRemoveFilter, + chips, + onRemove, onClearAll, }: ActiveFilterChipsProps) { + if (!chips.length) return null; + return ( - - {filters.map((filter) => ( - - {filter.label} - onRemoveFilter(filter.value)}> - - - - ))} - - Clear All - + + + {chips.map((chip) => ( + + {chip.label} + onRemove(chip.id)} + accessibilityLabel="Remove filter" + > + + + + ))} + + Clear All + + ); } diff --git a/clients/mobile/components/tasks/task-card.tsx b/clients/mobile/components/tasks/task-card.tsx index ebdfeefae..6f447fbc8 100644 --- a/clients/mobile/components/tasks/task-card.tsx +++ b/clients/mobile/components/tasks/task-card.tsx @@ -2,25 +2,43 @@ 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; + compact?: boolean; + onPress?: () => void; + onStart?: () => void; + onClaim?: () => void; + onMarkDone?: () => void; } function DotSeparator() { return ; } -export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { +export function TaskCard({ + task, + variant, + isExpanded, + compact, + onPress, + onStart, + onClaim, + onMarkDone, +}: TaskCardProps) { const isAssigned = variant === TASK_ASSIGNMENT_STATE.ASSIGNED; + const expanded = compact ? false : isExpanded; - if (isAssigned && isExpanded) { + if (isAssigned && expanded) { return ( - + {task.title} {task.priority} @@ -36,19 +54,31 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { {task.description && ( {task.description} )} - {}} - className="bg-blue-600 rounded-lg py-3 w-full items-center mt-3" - > - Start - - + {task.status === "in progress" ? ( + + Mark done + + ) : ( + + Start + + )} + ); } - if (isAssigned && !isExpanded) { + if (isAssigned && !expanded) { return ( - + {task.title} @@ -56,16 +86,23 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { - {}} className="p-1"> + - + ); } - if (!isAssigned && isExpanded) { + if (!isAssigned && expanded) { return ( - + {task.title} @@ -76,18 +113,21 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { {task.description} )} {}} + onPress={onClaim} className="bg-white border border-gray-300 rounded-lg py-3 w-full items-center mt-3" > Claim Task - + ); } // Compact unassigned return ( - + {task.title} @@ -96,9 +136,13 @@ export function TaskCard({ task, variant, isExpanded }: TaskCardProps) { - {}} 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..b1e0c212f --- /dev/null +++ b/clients/mobile/components/tasks/task-completion-modal.tsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Modal, + Pressable, + Text, + TextInput, + View, + useWindowDimensions, +} from "react-native"; +import ConfettiCannon from "react-native-confetti-cannon"; + +const primary = "#004FC5"; + +type TaskCompletionModalProps = { + visible: boolean; + taskTitle: string; + managerNote: string; + onChangeNote: (text: string) => void; + onClose: () => void; +}; + +export function TaskCompletionModal({ + visible, + taskTitle, + managerNote, + onChangeNote, + onClose, +}: TaskCompletionModalProps) { + const { width } = useWindowDimensions(); + const [confettiKey, setConfettiKey] = useState(0); + + useEffect(() => { + if (visible) setConfettiKey((k) => k + 1); + }, [visible]); + + const origin = useMemo(() => ({ x: width / 2, y: 0 }), [width]); + + return ( + + + e.stopPropagation()} + > + {visible ? ( + + ) : null} + + All Done! + {taskTitle} + + + Note for manager (optional) + + + + + 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..e024f1819 --- /dev/null +++ b/clients/mobile/components/tasks/task-detail-sheet.tsx @@ -0,0 +1,183 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import React, { forwardRef, useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { + TASK_ASSIGNMENT_STATE, + type TaskAssignmentState, +} from "@/constants/tasks"; +import type { Task } from "@/types/tasks"; + +const primary = "#004FC5"; + +type TaskDetailSheetProps = { + task: Task | null; + listVariant: TaskAssignmentState; + onDismiss: () => void; + onStart: (task: Task) => void; + onClaim: (task: Task) => void; + onMarkDone: (task: Task) => void; + onDrop: (task: Task) => void; + onFindCover: (task: Task) => void; +}; + +export const TaskDetailSheet = forwardRef< + BottomSheetModal, + TaskDetailSheetProps +>(function TaskDetailSheet( + { + task, + listVariant, + onDismiss, + onStart, + onClaim, + onMarkDone, + onDrop, + onFindCover, + }, + ref, +) { + const snapPoints = useMemo(() => ["50%", "90%"], []); + + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const assigned = listVariant === TASK_ASSIGNMENT_STATE.ASSIGNED; + const st = (task?.status ?? "").toLowerCase(); + + return ( + + + {!task ? ( + No task selected + ) : ( + <> + + {task.title} + + + + + Priority: {task.priority} + + + Department:{" "} + {task.department} + + + Location: {task.location} + + {task.dueTime ? ( + + Due: {task.dueTime} + + ) : null} + + Status: {task.status} + + + + {task.description ? ( + + {task.description} + + ) : null} + + + {assigned ? ( + <> + {st === "assigned" ? ( + onStart(task)} + className="rounded-lg py-3 items-center" + style={{ backgroundColor: primary }} + > + Start + + ) : null} + + {st === "assigned" ? ( + onFindCover(task)} + className="py-2" + > + + Find a Cover + + + ) : null} + + {st === "in progress" ? ( + onMarkDone(task)} + className="rounded-lg py-3 items-center bg-emerald-600" + > + + Mark done + + + ) : null} + + {st === "in progress" ? ( + onDrop(task)} className="py-2"> + + Drop task + + + ) : null} + + {st === "completed" ? ( + + This task is completed. + + ) : null} + + ) : ( + onClaim(task)} + className="rounded-lg py-3 items-center border-2" + style={{ borderColor: primary }} + > + + Claim Task + + + )} + + + + Activity + + + + Activity log coming soon. + + + + )} + + + ); +}); 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..ba7980ffb --- /dev/null +++ b/clients/mobile/components/tasks/task-filter-sheet.tsx @@ -0,0 +1,179 @@ +import { + BottomSheetBackdrop, + BottomSheetModal, + BottomSheetScrollView, +} from "@gorhom/bottom-sheet"; +import React, { forwardRef, useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; + +import { + TAB, + TASK_FILTER_DEPARTMENTS, + TASK_FILTER_PRIORITIES, + TASK_FILTER_STATUS_MY, + TASK_FILTER_STATUS_UNASSIGNED, + type TabName, + type TaskViewMode, +} from "@/constants/tasks"; +import type { TasksFilterState } from "@/types/tasks"; + +const primary = "#004FC5"; + +type TaskFilterSheetProps = { + tab: TabName; + draft: TasksFilterState; + setDraft: React.Dispatch>; + viewMode: TaskViewMode; + setViewMode: (mode: TaskViewMode) => void; + onApply: () => void; +}; + +function OptionChip({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +export const TaskFilterSheet = forwardRef< + BottomSheetModal, + TaskFilterSheetProps +>(function TaskFilterSheet( + { tab, draft, setDraft, viewMode, setViewMode, onApply }, + ref, +) { + // Tall first snap so filters + Apply are visible without dragging higher first. + const snapPoints = useMemo(() => ["90%", "95%"], []); + const renderBackdrop = useCallback( + (props: React.ComponentProps) => ( + + ), + [], + ); + + const statusOptions = + tab === TAB.MY_TASKS + ? TASK_FILTER_STATUS_MY + : TASK_FILTER_STATUS_UNASSIGNED; + + return ( + + + + Filters + setDraft({})}> + + Reset + + + + + + Department + + + {TASK_FILTER_DEPARTMENTS.map((d) => ( + + setDraft((prev) => ({ + ...prev, + department: prev.department === d.value ? undefined : d.value, + })) + } + /> + ))} + + + + Priority + + + {TASK_FILTER_PRIORITIES.map((p) => ( + + setDraft((prev) => ({ + ...prev, + priority: prev.priority === p.value ? undefined : p.value, + })) + } + /> + ))} + + + Status + + {statusOptions.map((s) => ( + + setDraft((prev) => ({ + ...prev, + status: prev.status === s.value ? undefined : s.value, + })) + } + /> + ))} + + + View + + setViewMode("default")} + /> + setViewMode("compact")} + /> + + + + Apply Filters + + + + ); +}); diff --git a/clients/mobile/components/tasks/task-list.tsx b/clients/mobile/components/tasks/task-list.tsx index 1de565104..81d14f856 100644 --- a/clients/mobile/components/tasks/task-list.tsx +++ b/clients/mobile/components/tasks/task-list.tsx @@ -1,21 +1,50 @@ import { FlatList, ListRenderItem } 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]; + compact: boolean; + onEndReached?: () => void; + isFetchingNextPage?: boolean; + onPressTask: (task: Task) => void; + onStart: (task: Task) => void; + onClaim: (task: Task) => void; + onMarkDone: (task: Task) => void; } -export function TaskList({ tasks, variant }: TaskListProps) { +export function TaskList({ + tasks, + variant, + compact, + onEndReached, + isFetchingNextPage, + onPressTask, + onStart, + onClaim, + onMarkDone, +}: TaskListProps) { const renderItem: ListRenderItem = ({ item, index }) => { - const isExpanded = - variant === TASK_ASSIGNMENT_STATE.ASSIGNED + const isExpanded = compact + ? false + : variant === TASK_ASSIGNMENT_STATE.ASSIGNED ? index === 0 : item.priority === "High"; - return ; + return ( + onPressTask(item)} + onStart={() => onStart(item)} + onClaim={() => onClaim(item)} + onMarkDone={() => onMarkDone(item)} + /> + ); }; return ( @@ -23,8 +52,11 @@ 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} + onEndReachedThreshold={0.4} + onEndReached={onEndReached} + refreshing={Boolean(isFetchingNextPage)} /> ); } diff --git a/clients/mobile/components/tasks/tasks-header.tsx b/clients/mobile/components/tasks/tasks-header.tsx index d21ed55fe..b8f7dd666 100644 --- a/clients/mobile/components/tasks/tasks-header.tsx +++ b/clients/mobile/components/tasks/tasks-header.tsx @@ -1,18 +1,37 @@ import Feather from "@expo/vector-icons/Feather"; import { Pressable, Text, View } from "react-native"; -export function TasksHeader() { +type TasksHeaderProps = { + onOpenFilters: () => void; + onToggleSearch: () => void; + onOpenNotifications: () => void; + searchActive: boolean; +}; + +export function TasksHeader({ + onOpenFilters, + onToggleSearch, + onOpenNotifications, + searchActive, +}: TasksHeaderProps) { return ( - + Tasks - {}}> - + + - {}}> + - {}}> + diff --git a/clients/mobile/constants/tasks.ts b/clients/mobile/constants/tasks.ts index d5f229d45..ea115eeae 100644 --- a/clients/mobile/constants/tasks.ts +++ b/clients/mobile/constants/tasks.ts @@ -12,3 +12,28 @@ export const TASK_ASSIGNMENT_STATE = { export type TaskAssignmentState = (typeof TASK_ASSIGNMENT_STATE)[keyof typeof TASK_ASSIGNMENT_STATE]; + +export type TaskViewMode = "default" | "compact"; + +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: "High", value: "high" }, + { label: "Medium", value: "medium" }, + { label: "Low", value: "low" }, +] as const; + +export const TASK_FILTER_STATUS_MY = [ + { label: "Assigned", value: "assigned" }, + { label: "In progress", value: "in progress" }, + { label: "Completed", value: "completed" }, +] as const; + +export const TASK_FILTER_STATUS_UNASSIGNED = [ + { label: "Pending", value: "pending" }, +] as const; diff --git a/clients/mobile/hooks/use-tasks-feed.ts b/clients/mobile/hooks/use-tasks-feed.ts new file mode 100644 index 000000000..8970dbbbd --- /dev/null +++ b/clients/mobile/hooks/use-tasks-feed.ts @@ -0,0 +1,67 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useAPIClient } from "@shared/api/client"; +import { API_ENDPOINTS } from "@shared/api/endpoints"; +import { TAB, type TabName } from "@/constants/tasks"; +import type { CursorPage, Task, TasksFilterState } from "@/types/tasks"; + +type BackendTask = { + id: string; + title: string; + priority: string; + department: string; + location: string; + description?: string; + due_time?: string; + status: string; + is_assigned: boolean; +}; + +const toUiPriority = (priority: string): Task["priority"] => { + const normalized = priority.toLowerCase(); + if (normalized === "urgent" || normalized === "high") return "High"; + if (normalized === "middle" || normalized === "medium") return "Middle"; + return "Low"; +}; + +const toUiTask = (task: BackendTask): Task => ({ + id: task.id, + title: task.title, + priority: toUiPriority(task.priority), + department: task.department, + location: task.location, + description: task.description, + dueTime: task.due_time, + status: (task.status ?? "").toLowerCase(), + isAssigned: task.is_assigned, +}); + +export const useTasksFeed = (tab: TabName, filters: TasksFilterState) => { + const api = useAPIClient(); + + return useInfiniteQuery({ + queryKey: ["tasks-feed", tab, filters], + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const params: Record = { + tab: tab === TAB.MY_TASKS ? "my" : "unassigned", + limit: 20, + }; + if (pageParam) params.cursor = pageParam; + if (filters.department?.trim()) + params.department = filters.department.trim(); + if (filters.priority?.trim()) params.priority = filters.priority.trim(); + if (filters.status?.trim()) params.status = filters.status.trim(); + + const page = await api.get>( + API_ENDPOINTS.TASKS, + params, + ); + const rawItems = Array.isArray(page.items) ? page.items : []; + return { + ...page, + items: rawItems.map(toUiTask), + } satisfies CursorPage; + }, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + }); +}; diff --git a/clients/mobile/package-lock.json b/clients/mobile/package-lock.json index 110d04881..eaf7bb993 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", @@ -35,6 +36,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-confetti-cannon": "^1.5.2", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", @@ -2409,6 +2411,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, @@ -12945,6 +12986,12 @@ } } }, + "node_modules/react-native-confetti-cannon": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-native-confetti-cannon/-/react-native-confetti-cannon-1.5.2.tgz", + "integrity": "sha512-IZuWjlW7QsdxEGNnvpD6W+7iKCCQhnd5BvuNvMtirU7Nxm8WS2N6LPGMBz1ZYDuusG+GRZkoXXTNCdoAAGpCTg==", + "license": "MIT" + }, "node_modules/react-native-css-interop": { "version": "0.2.1", "license": "MIT", diff --git a/clients/mobile/package.json b/clients/mobile/package.json index f07dd9ab5..c42eae9a0 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", @@ -42,6 +43,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-confetti-cannon": "^1.5.2", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", @@ -58,9 +60,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..d2401ce00 --- /dev/null +++ b/clients/mobile/types/tasks.ts @@ -0,0 +1,27 @@ +export type Priority = "High" | "Middle" | "Low"; + +export interface Task { + id: string; + title: string; + priority: Priority; + department: string; + location: string; + description?: string; + dueTime?: string; + /** Raw API status: pending | assigned | in progress | completed */ + status: string; + isAssigned: boolean; +} + +/** Query filters for GET /tasks (values match DB / API). */ +export type TasksFilterState = { + department?: string; + priority?: string; + status?: string; +}; + +export interface CursorPage { + items: T[]; + next_cursor: string | null; + has_more: boolean; +} diff --git a/clients/shared/src/api/client.ts b/clients/shared/src/api/client.ts index 752e14d19..1604986eb 100644 --- a/clients/shared/src/api/client.ts +++ b/clients/shared/src/api/client.ts @@ -8,7 +8,6 @@ export const createRequest = ( getToken: () => Promise, baseUrl: string, ) => { - const hardCodedHotelId = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" return async (config: RequestConfig): Promise => { let fullUrl = `${baseUrl}${config.url}`; if (config.params && Object.keys(config.params).length > 0) { @@ -24,7 +23,6 @@ export const createRequest = ( headers: { "Content-Type": "application/json", ...(token && { Authorization: `Bearer ${token}` }), - "X-Hotel-ID": hardCodedHotelId, ...config.headers, }, body: config.data ? JSON.stringify(config.data) : undefined, diff --git a/clients/shared/src/api/endpoints.ts b/clients/shared/src/api/endpoints.ts index 6abd58fdc..397ea61f1 100644 --- a/clients/shared/src/api/endpoints.ts +++ b/clients/shared/src/api/endpoints.ts @@ -1 +1,6 @@ -// API endpoint constants +export const API_ENDPOINTS = { + TASKS: "/api/v1/tasks", + task: (id: string) => `/api/v1/tasks/${id}`, + taskClaim: (id: string) => `/api/v1/tasks/${id}/claim`, + taskDrop: (id: string) => `/api/v1/tasks/${id}/drop`, +} as const; diff --git a/clients/shared/tsconfig.json b/clients/shared/tsconfig.json index 46de0c48f..8d92de00b 100644 --- a/clients/shared/tsconfig.json +++ b/clients/shared/tsconfig.json @@ -16,9 +16,8 @@ "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, - "baseUrl": ".", "paths": { - "@/*": ["./src/*"], + "@/*": ["./src/*"] } }, "include": ["src/**/*.ts", "src/**/*.tsx"], diff --git a/clients/web/src/components/home/HomeHeader.tsx b/clients/web/src/components/home/HomeHeader.tsx new file mode 100644 index 000000000..45781ef2e --- /dev/null +++ b/clients/web/src/components/home/HomeHeader.tsx @@ -0,0 +1,10 @@ +export function HomeHeader() { + return ( +
+

Home

+

+ Overview of all tasks currently at play +

+
+ ); +} diff --git a/clients/web/src/components/rooms/RoomsHeader.tsx b/clients/web/src/components/rooms/RoomsHeader.tsx new file mode 100644 index 000000000..d58b8ef0f --- /dev/null +++ b/clients/web/src/components/rooms/RoomsHeader.tsx @@ -0,0 +1,22 @@ +import { FloorDropdown } from "./FloorDropdown"; +import { RoomsFilterPopover } from "@/components/rooms/RoomsFilterPopover"; + +type RoomsHeaderProps = { + selectedFloors: Array; + onChangeSelectedFloors: (floors: Array) => void; +}; + +export function RoomsHeader({ + selectedFloors, + onChangeSelectedFloors, +}: RoomsHeaderProps) { + return ( +
+ + +
+ ); +} diff --git a/clients/web/tsconfig.json b/clients/web/tsconfig.json index 2022b26ab..035bdf98f 100644 --- a/clients/web/tsconfig.json +++ b/clients/web/tsconfig.json @@ -27,7 +27,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@shared": ["../shared/src"],