commit 6ffc0d2c99012310838b0fad1c87c81dd545e43f Author: Daniel Noulet Date: Sat Sep 19 20:31:14 2026 +0200 initial commit diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..064fba8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,93 @@ +# Copilot instructions + +## Build, test, and lint + +Run repository-wide commands from the root: + +```bash +make dev # build and run PostgreSQL, API, and frontend +make db-up # start only PostgreSQL +make migrate # apply Goose migrations +make migrate-down # roll back one migration +make test # backend and frontend unit/component tests +make test-integration # PostgreSQL-backed repository tests; database must be running +make lint # go vet and ESLint +make build # compile the API and production frontend +``` + +Run focused tests from the owning application: + +```bash +cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' +cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" +``` + +The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Frontend +formatting can be checked with `cd frontend && npm run format:check`; backend files +are formatted with `gofmt`. + +## Architecture + +The repository has two applications: + +- `backend/` is a Go HTTP API. `cmd/api` wires configuration, PostgreSQL, security, + services, handlers, and routing. Requests flow through + `internal/web/{router,handler,model,mapper}` to + `internal/service/{model,services}` and then + `internal/database/{repository,mapper,entity}`. Do not bypass these mapping + boundaries by exposing GORM entities from handlers. +- `frontend/` is a React SPA. `src/App.tsx` owns routing/layout/screens, + `src/api.ts` is the typed HTTP boundary, and `src/types.ts` contains client domain + types. Vite proxies `/api` to the Go service in development. + +Goose SQL files in `backend/migrations/` are the database schema source of truth. +The initial migration also supplies representative jobs, questions, participants, +traits, and the local interviewer account. + +Authentication is custom bearer-token authentication: passwords are bcrypt hashes, +`POST /api/auth/login` issues an HMAC-signed JWT, and all domain routes require that +token. Never return password hashes or participant photo/CV bytes in JSON; those +files have dedicated endpoints. + +CV ingestion stores the original bytes and extracts text from plain text, PDF, or +DOCX input. Trait extraction only considers normalized traits already present in +the database. Candidate matching compares a participant's extracted traits with a +job's labels, required skills, and additional skills; include matches only at the +`service.CandidateThreshold` of 60% or higher. + +Questions are permanent, reusable library records and jobs own many-to-many links +to them. Detaching removes only the link. The lifecycle is +`draft -> published -> deprecated`; publishing requires at least one question, and +only published jobs can create invitations. Once an invitation exists, its question +set cannot be changed, and its duration is snapshotted so later job edits do not +alter an active interview. + +Invitation URLs use an opaque UUID token and public `/api/interviews/{token}` routes; +they do not use interviewer JWT authentication. An invitation progresses from +`pending` to `accepted` to `completed`. Answers may only be saved while accepted +and before the snapshotted timer expires. + +## Repository conventions + +- Every Go interface name starts with `I` (`IStore`, `IJobService`, + `IParticipantService`, `ITokenManager`). +- Keep transport, service, and persistence structs separate. Conversion belongs in + the adjacent `mapper` package, not in handlers or GORM entities. +- Question types are the exact API values `open`, `multiple_choice`, `radio`, and + `code_review`. Multiple-choice and radio questions require at least two options. +- Never delete question records through job operations. Use + `job_posting_questions` to attach/detach, and use the `deprecated` flag to retire + library questions. Full question edits use `PUT /api/questions/{id}`; deprecation + remains a separate `PATCH`. +- The question create/edit form builds multiple-choice and radio options one at a + time. Keep option editing state separate from the saved options array and require + at least two options for those types. Open and code-review questions instead + require a `referenceAnswer` textarea. Never include that interviewer-only answer + in public interview responses. +- Traits are de-duplicated case-insensitively through their normalized database + value. Labels and both skill groups all reuse the same `traits` table. +- Participant creation is multipart form data using `firstName`, `lastName`, + `birthday`, `email`, `contactInfo`, `photo`, and `cv`. +- Return explicit errors. Handlers convert expected validation/not-found/authentication + failures to HTTP responses and log unexpected failures rather than silently + falling back to demo data. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecc768e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +.env +backend/api +frontend/node_modules/ +frontend/dist/ +frontend/coverage/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c642c01 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +SHELL := /bin/sh + +DATABASE_URL ?= postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable +JWT_SECRET ?= local-development-secret-change-me-12345 +GOOSE := cd backend && go run github.com/pressly/goose/v3/cmd/goose@v3.26.0 + +.PHONY: dev db-up db-down migrate migrate-down backend frontend test test-backend test-frontend test-integration lint build + +dev: + docker compose up --build + +db-up: + docker compose up -d db + +db-down: + docker compose down + +migrate: + $(GOOSE) -dir migrations postgres "$(DATABASE_URL)" up + +migrate-down: + $(GOOSE) -dir migrations postgres "$(DATABASE_URL)" down + +backend: + cd backend && DATABASE_URL="$(DATABASE_URL)" JWT_SECRET="$(JWT_SECRET)" go run ./cmd/api + +frontend: + cd frontend && npm run dev + +test: test-backend test-frontend + +test-backend: + cd backend && go test ./... + +test-frontend: + cd frontend && npm test + +test-integration: + cd backend && TEST_DATABASE_URL="$(DATABASE_URL)" go test -tags=integration ./internal/database/repository + +lint: + cd backend && go vet ./... + cd frontend && npm run lint + +build: + cd backend && go build ./... + cd frontend && npm run build diff --git a/README.md b/README.md new file mode 100644 index 0000000..9aafd2f --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# go-guess + +Recruitment workspace for interviewers to define job profiles and a reusable +question library, manage participants, extract known traits from uploaded CVs, +invite candidates who match at least 60% of a posting's labels and skills, and run +timed participant interviews. + +## Stack + +- Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs +- React 19.3, TypeScript, and Vite +- PostgreSQL 18 + +## Run locally + +The complete stack runs with: + +```bash +make dev +``` + +Open and sign in with: + +- Email: `interviewer@go-guess.local` +- Password: `admin123` + +To run the applications outside containers: + +```bash +make db-up +make migrate +make backend +make frontend +``` + +The API listens on . Set a different secure `JWT_SECRET` +outside local development. + +## Quality commands + +```bash +make test +make test-integration +make lint +make build +``` + +Run one backend test: + +```bash +cd backend && go test ./internal/service -run '^TestCandidatesRequireSixtyPercent$' +``` + +Run one frontend test: + +```bash +cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" +``` + +## Architecture + +Requests enter the Chi router and web handlers under `backend/internal/web`. +Web request/response models are translated to service models before business logic +runs. Services own validation, CV trait extraction, and candidate scoring. The +repository maps service models to GORM entities and persists them in PostgreSQL. + +The React application uses a typed API client and routes for authentication, job +postings, the searchable question library, participants, candidate matches, and +invitations. Public `/interview/:token` routes provide the participant welcome, +timed question navigation, autosaved answers, and completion flow without requiring +an interviewer login. Vite proxies `/api` to the Go service during local development. + +Goose migrations in `backend/migrations` are the schema source of truth and include +representative local data. Uploaded CVs are stored as bytes; plain text, PDF, and +DOCX content is inspected for normalized traits already defined by job postings. + +Jobs move through `draft`, `published`, and `deprecated` states. Questions are +editable, permanent library records: jobs attach and detach links rather than +owning or deleting questions. Deprecated questions remain available for historical +jobs but cannot be newly attached. Invitations snapshot the interview duration and +can only be generated for matching participants on published jobs. + +Open and code-review questions store an interviewer-only reference answer. +Multiple-choice and radio questions instead store a list of possible responses. +Reference answers are deliberately omitted from the public participant interview +payload. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5aa5e89 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.27.1-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +FROM golang:1.27.1-alpine AS tools +RUN GOBIN=/out go install github.com/pressly/goose/v3/cmd/goose@v3.26.0 + +FROM alpine:3.22 +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app +COPY --from=build /out/api /usr/local/bin/api +COPY --from=tools /out/goose /usr/local/bin/goose +COPY migrations ./migrations +USER app +EXPOSE 8080 +CMD ["sh", "-c", "goose -dir migrations postgres \"$DATABASE_URL\" up && exec api"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..9d9aba2 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/danyel/go-guess/backend/internal/config" + "github.com/danyel/go-guess/backend/internal/database" + "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service" + "github.com/danyel/go-guess/backend/internal/web/handler" + "github.com/danyel/go-guess/backend/internal/web/router" +) + +func main() { + cfg, err := config.Load() + if err != nil { + slog.Error("load configuration", "error", err) + os.Exit(1) + } + db, err := database.Open(cfg.DatabaseURL) + if err != nil { + slog.Error("connect database", "error", err) + os.Exit(1) + } + sqlDB, err := db.DB() + if err != nil { + slog.Error("get database connection", "error", err) + os.Exit(1) + } + defer sqlDB.Close() + + store := repository.New(db) + tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL) + invitations := service.NewInvitationService(store, cfg.FrontendURL) + api := router.New( + handler.New( + service.NewAuthService(store, tokens), + service.NewJobService(store), + service.NewQuestionService(store), + service.NewParticipantService(store), + invitations, + cfg.MaxUploadMB, + cfg.FrontendURL, + ), + tokens, + cfg.FrontendURL, + ) + server := &http.Server{ + Addr: cfg.Address, Handler: api, ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("API listening", "address", cfg.Address) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("serve API", "error", err) + os.Exit(1) + } + }() + + stop, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + <-stop.Done() + ctx, cancelShutdown := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelShutdown() + if err := server.Shutdown(ctx); err != nil { + slog.Error("shutdown API", "error", err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..177c5c7 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,25 @@ +module github.com/danyel/go-guess/backend + +go 1.27.1 + +require ( + github.com/go-chi/chi/v5 v5.2.3 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.5 + github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 + golang.org/x/crypto v0.43.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.0 +) + +require ( + 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/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/stretchr/testify v1.11.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.30.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..ebef8a7 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,44 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +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.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU= +github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..c396ed2 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + Address string + DatabaseURL string + JWTSecret string + TokenTTL time.Duration + MaxUploadMB int64 + FrontendURL string +} + +func Load() (Config, error) { + ttl, err := time.ParseDuration(env("TOKEN_TTL", "8h")) + if err != nil { + return Config{}, fmt.Errorf("parse TOKEN_TTL: %w", err) + } + maxUploadMB, err := strconv.ParseInt(env("MAX_UPLOAD_MB", "10"), 10, 64) + if err != nil || maxUploadMB < 1 { + return Config{}, fmt.Errorf("MAX_UPLOAD_MB must be a positive integer") + } + cfg := Config{ + Address: env("ADDRESS", ":8080"), + DatabaseURL: env("DATABASE_URL", "postgres://go_guess:go_guess@localhost:5432/go_guess?sslmode=disable"), + JWTSecret: os.Getenv("JWT_SECRET"), + TokenTTL: ttl, + MaxUploadMB: maxUploadMB, + FrontendURL: env("FRONTEND_URL", "http://localhost:5173"), + } + if len(cfg.JWTSecret) < 32 { + return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters") + } + return cfg, nil +} + +func env(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..233775c --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,16 @@ +package database + +import ( + "fmt" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func Open(databaseURL string) (*gorm.DB, error) { + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + return db, nil +} diff --git a/backend/internal/database/entity/models.go b/backend/internal/database/entity/models.go new file mode 100644 index 0000000..453b14d --- /dev/null +++ b/backend/internal/database/entity/models.go @@ -0,0 +1,92 @@ +package entity + +import "time" + +type User struct { + ID uint `gorm:"primaryKey"` + Email string `gorm:"uniqueIndex;size:320;not null"` + PasswordHash string `gorm:"not null"` + Role string `gorm:"size:32;not null"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type JobPosting struct { + ID uint `gorm:"primaryKey"` + Title string `gorm:"size:200;not null"` + Description string `gorm:"type:text;not null"` + Seniority string `gorm:"size:50;not null"` + Position string `gorm:"size:120;not null"` + Status string `gorm:"size:32;not null;default:draft"` + DurationMinutes int `gorm:"not null;default:60"` + Labels []Trait `gorm:"many2many:job_posting_labels"` + RequiredSkills []Trait `gorm:"many2many:job_required_skills"` + AdditionalSkills []Trait `gorm:"many2many:job_additional_skills"` + Questions []Question `gorm:"many2many:job_posting_questions"` + Invitations []Invitation `gorm:"foreignKey:JobID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type Trait struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"uniqueIndex;size:100;not null"` + Normalized string `gorm:"uniqueIndex;size:100;not null"` +} + +type Question struct { + ID uint `gorm:"primaryKey"` + Text string `gorm:"type:text;not null"` + Type string `gorm:"size:32;not null"` + ReferenceAnswer string `gorm:"type:text;not null;default:''"` + Deprecated bool `gorm:"not null;default:false"` + Options []QuestionOption `gorm:"foreignKey:QuestionID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type QuestionOption struct { + ID uint `gorm:"primaryKey"` + QuestionID uint `gorm:"index;not null"` + Text string `gorm:"type:text;not null"` +} + +type Participant struct { + ID uint `gorm:"primaryKey"` + FirstName string `gorm:"size:100;not null"` + LastName string `gorm:"size:100;not null"` + Birthday *time.Time + Email string `gorm:"uniqueIndex;size:320;not null"` + ContactInfo string `gorm:"type:text"` + Photo []byte `gorm:"type:bytea"` + PhotoType string `gorm:"size:100"` + CV []byte `gorm:"type:bytea"` + CVFilename string `gorm:"size:255"` + Traits []Trait `gorm:"many2many:participant_traits"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type Invitation struct { + ID uint `gorm:"primaryKey"` + JobID uint `gorm:"index;not null"` + ParticipantID uint `gorm:"index;not null"` + Token string `gorm:"uniqueIndex;size:64;not null"` + Status string `gorm:"size:32;not null;default:pending"` + DurationMinutes int `gorm:"not null"` + AcceptedAt *time.Time + CompletedAt *time.Time + Answers []InvitationAnswer `gorm:"foreignKey:InvitationID;constraint:OnDelete:CASCADE"` + Job JobPosting + Participant Participant + CreatedAt time.Time + UpdatedAt time.Time +} + +type InvitationAnswer struct { + ID uint `gorm:"primaryKey"` + InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"` + QuestionID uint `gorm:"uniqueIndex:invitation_question;not null"` + Answer string `gorm:"type:text;not null"` + UpdatedAt time.Time +} diff --git a/backend/internal/database/mapper/mapper.go b/backend/internal/database/mapper/mapper.go new file mode 100644 index 0000000..44ed104 --- /dev/null +++ b/backend/internal/database/mapper/mapper.go @@ -0,0 +1,68 @@ +package mapper + +import ( + dbmodel "github.com/danyel/go-guess/backend/internal/database/entity" + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" +) + +func UserToService(value dbmodel.User) servicemodel.User { + return servicemodel.User{ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, Role: value.Role} +} + +func JobToService(value dbmodel.JobPosting) servicemodel.JobPosting { + return servicemodel.JobPosting{ + ID: value.ID, Title: value.Title, Description: value.Description, + Seniority: value.Seniority, Position: value.Position, Status: value.Status, + DurationMinutes: value.DurationMinutes, + Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills), + AdditionalSkills: traitsToService(value.AdditionalSkills), + Questions: questionsToService(value.Questions), CreatedAt: value.CreatedAt, + } +} + +func ParticipantToService(value dbmodel.Participant) servicemodel.Participant { + return servicemodel.Participant{ + ID: value.ID, FirstName: value.FirstName, LastName: value.LastName, + Birthday: value.Birthday, Email: value.Email, ContactInfo: value.ContactInfo, + Photo: value.Photo, PhotoType: value.PhotoType, CV: value.CV, + CVFilename: value.CVFilename, Traits: traitsToService(value.Traits), CreatedAt: value.CreatedAt, + } +} + +func traitsToService(values []dbmodel.Trait) []servicemodel.Trait { + result := make([]servicemodel.Trait, len(values)) + for i, value := range values { + result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name} + } + return result +} + +func questionsToService(values []dbmodel.Question) []servicemodel.Question { + result := make([]servicemodel.Question, len(values)) + for i, value := range values { + options := make([]string, len(value.Options)) + for j, option := range value.Options { + options[j] = option.Text + } + result[i] = servicemodel.Question{ + ID: value.ID, Text: value.Text, Type: value.Type, + Options: options, ReferenceAnswer: value.ReferenceAnswer, + Deprecated: value.Deprecated, + } + } + return result +} + +func QuestionToService(value dbmodel.Question) servicemodel.Question { + return questionsToService([]dbmodel.Question{value})[0] +} + +func InvitationToService(value dbmodel.Invitation) servicemodel.Invitation { + return servicemodel.Invitation{ + ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID, + ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName, + ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status, + DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt, + CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, + } +} diff --git a/backend/internal/database/repository/store.go b/backend/internal/database/repository/store.go new file mode 100644 index 0000000..c61b864 --- /dev/null +++ b/backend/internal/database/repository/store.go @@ -0,0 +1,408 @@ +package repository + +import ( + "context" + "errors" + "strings" + "time" + + dbmodel "github.com/danyel/go-guess/backend/internal/database/entity" + dbmapper "github.com/danyel/go-guess/backend/internal/database/mapper" + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrNotFound = errors.New("record not found") + ErrConflict = errors.New("record conflicts with existing data") +) + +type IStore interface { + FindUserByEmail(context.Context, string) (servicemodel.User, error) + ListJobs(context.Context) ([]servicemodel.JobPosting, error) + GetJob(context.Context, uint) (servicemodel.JobPosting, error) + CreateJob(context.Context, servicemodel.JobPosting) (servicemodel.JobPosting, error) + UpdateJob(context.Context, uint, string, int) (servicemodel.JobPosting, error) + ListQuestions(context.Context, string) ([]servicemodel.Question, error) + GetQuestion(context.Context, uint) (servicemodel.Question, error) + CreateQuestion(context.Context, servicemodel.Question) (servicemodel.Question, error) + UpdateQuestion(context.Context, uint, servicemodel.Question) (servicemodel.Question, error) + SetQuestionDeprecated(context.Context, uint, bool) (servicemodel.Question, error) + AttachQuestion(context.Context, uint, uint) error + DetachQuestion(context.Context, uint, uint) error + ListParticipants(context.Context) ([]servicemodel.Participant, error) + GetParticipant(context.Context, uint) (servicemodel.Participant, error) + CreateParticipant(context.Context, servicemodel.Participant) (servicemodel.Participant, error) + AllTraits(context.Context) ([]servicemodel.Trait, error) + ListInvitations(context.Context, uint) ([]servicemodel.Invitation, error) + CreateInvitation(context.Context, servicemodel.Invitation) (servicemodel.Invitation, error) + GetInterview(context.Context, string) (servicemodel.Interview, error) + AcceptInvitation(context.Context, string, time.Time) error + SaveAnswer(context.Context, string, uint, string) error + CompleteInvitation(context.Context, string, time.Time) error +} + +type Store struct { + db *gorm.DB +} + +func New(db *gorm.DB) *Store { + return &Store{db: db} +} + +func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel.User, error) { + var value dbmodel.User + if err := s.db.WithContext(ctx).Where("lower(email) = ?", strings.ToLower(email)).First(&value).Error; err != nil { + return servicemodel.User{}, mapError(err) + } + return dbmapper.UserToService(value), nil +} + +func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) { + var values []dbmodel.JobPosting + if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.JobPosting, len(values)) + for i, value := range values { + result[i] = dbmapper.JobToService(value) + } + return result, nil +} + +func (s *Store) GetJob(ctx context.Context, id uint) (servicemodel.JobPosting, error) { + var value dbmodel.JobPosting + if err := preloadJob(s.db.WithContext(ctx)).First(&value, id).Error; err != nil { + return servicemodel.JobPosting{}, mapError(err) + } + return dbmapper.JobToService(value), nil +} + +func (s *Store) CreateJob(ctx context.Context, value servicemodel.JobPosting) (servicemodel.JobPosting, error) { + entity := dbmodel.JobPosting{ + Title: value.Title, Description: value.Description, Seniority: value.Seniority, + Position: value.Position, Status: value.Status, DurationMinutes: value.DurationMinutes, + } + var err error + entity.Labels, err = s.resolveTraits(ctx, value.Labels) + if err != nil { + return servicemodel.JobPosting{}, err + } + entity.RequiredSkills, err = s.resolveTraits(ctx, value.RequiredSkills) + if err != nil { + return servicemodel.JobPosting{}, err + } + entity.AdditionalSkills, err = s.resolveTraits(ctx, value.AdditionalSkills) + if err != nil { + return servicemodel.JobPosting{}, err + } + if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { + return servicemodel.JobPosting{}, mapError(err) + } + return s.GetJob(ctx, entity.ID) +} + +func (s *Store) UpdateJob(ctx context.Context, id uint, status string, durationMinutes int) (servicemodel.JobPosting, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.JobPosting{}).Where("id = ?", id). + Updates(map[string]any{"status": status, "duration_minutes": durationMinutes}) + if result.Error != nil { + return servicemodel.JobPosting{}, result.Error + } + if result.RowsAffected == 0 { + return servicemodel.JobPosting{}, ErrNotFound + } + return s.GetJob(ctx, id) +} + +func (s *Store) ListQuestions(ctx context.Context, search string) ([]servicemodel.Question, error) { + query := s.db.WithContext(ctx).Preload("Options").Order("deprecated, created_at DESC") + if search = strings.TrimSpace(search); search != "" { + query = query.Where("text ILIKE ?", "%"+search+"%") + } + var values []dbmodel.Question + if err := query.Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.Question, len(values)) + for i, value := range values { + result[i] = dbmapper.QuestionToService(value) + } + return result, nil +} + +func (s *Store) GetQuestion(ctx context.Context, id uint) (servicemodel.Question, error) { + var value dbmodel.Question + if err := s.db.WithContext(ctx).Preload("Options").First(&value, id).Error; err != nil { + return servicemodel.Question{}, mapError(err) + } + return dbmapper.QuestionToService(value), nil +} + +func (s *Store) CreateQuestion(ctx context.Context, value servicemodel.Question) (servicemodel.Question, error) { + options := make([]dbmodel.QuestionOption, len(value.Options)) + for i, option := range value.Options { + options[i] = dbmodel.QuestionOption{Text: option} + } + entity := dbmodel.Question{ + Text: value.Text, Type: value.Type, Options: options, + ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated, + } + if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { + return servicemodel.Question{}, mapError(err) + } + return s.GetQuestion(ctx, entity.ID) +} + +func (s *Store) UpdateQuestion(ctx context.Context, id uint, value servicemodel.Question) (servicemodel.Question, error) { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.Model(&dbmodel.Question{}).Where("id = ?", id). + Updates(map[string]any{ + "text": value.Text, "type": value.Type, "reference_answer": value.ReferenceAnswer, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrNotFound + } + if err := tx.Where("question_id = ?", id).Delete(&dbmodel.QuestionOption{}).Error; err != nil { + return err + } + if len(value.Options) == 0 { + return nil + } + options := make([]dbmodel.QuestionOption, len(value.Options)) + for i, option := range value.Options { + options[i] = dbmodel.QuestionOption{QuestionID: id, Text: option} + } + return tx.Create(&options).Error + }) + if err != nil { + return servicemodel.Question{}, mapError(err) + } + return s.GetQuestion(ctx, id) +} + +func (s *Store) SetQuestionDeprecated(ctx context.Context, id uint, deprecated bool) (servicemodel.Question, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.Question{}).Where("id = ?", id). + Update("deprecated", deprecated) + if result.Error != nil { + return servicemodel.Question{}, result.Error + } + if result.RowsAffected == 0 { + return servicemodel.Question{}, ErrNotFound + } + return s.GetQuestion(ctx, id) +} + +func (s *Store) AttachQuestion(ctx context.Context, jobID, questionID uint) error { + var job dbmodel.JobPosting + if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil { + return mapError(err) + } + var question dbmodel.Question + if err := s.db.WithContext(ctx).First(&question, questionID).Error; err != nil { + return mapError(err) + } + var invitations int64 + if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID). + Count(&invitations).Error; err != nil { + return err + } + if invitations > 0 { + return ErrConflict + } + return mapError(s.db.WithContext(ctx).Model(&job).Association("Questions").Append(&question)) +} + +func (s *Store) DetachQuestion(ctx context.Context, jobID, questionID uint) error { + var job dbmodel.JobPosting + if err := s.db.WithContext(ctx).First(&job, jobID).Error; err != nil { + return mapError(err) + } + var invitations int64 + if err := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}).Where("job_id = ?", jobID). + Count(&invitations).Error; err != nil { + return err + } + if invitations > 0 { + return ErrConflict + } + return s.db.WithContext(ctx).Model(&job).Association("Questions"). + Delete(&dbmodel.Question{ID: questionID}) +} + +func (s *Store) ListParticipants(ctx context.Context) ([]servicemodel.Participant, error) { + var values []dbmodel.Participant + if err := s.db.WithContext(ctx).Preload("Traits").Order("last_name, first_name").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.Participant, len(values)) + for i, value := range values { + result[i] = dbmapper.ParticipantToService(value) + } + return result, nil +} + +func (s *Store) GetParticipant(ctx context.Context, id uint) (servicemodel.Participant, error) { + var value dbmodel.Participant + if err := s.db.WithContext(ctx).Preload("Traits").First(&value, id).Error; err != nil { + return servicemodel.Participant{}, mapError(err) + } + return dbmapper.ParticipantToService(value), nil +} + +func (s *Store) CreateParticipant(ctx context.Context, value servicemodel.Participant) (servicemodel.Participant, error) { + entity := dbmodel.Participant{ + FirstName: value.FirstName, LastName: value.LastName, Birthday: value.Birthday, + Email: value.Email, ContactInfo: value.ContactInfo, Photo: value.Photo, + PhotoType: value.PhotoType, CV: value.CV, CVFilename: value.CVFilename, + } + var err error + entity.Traits, err = s.resolveTraits(ctx, value.Traits) + if err != nil { + return servicemodel.Participant{}, err + } + if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { + return servicemodel.Participant{}, mapError(err) + } + return s.GetParticipant(ctx, entity.ID) +} + +func (s *Store) AllTraits(ctx context.Context) ([]servicemodel.Trait, error) { + var values []dbmodel.Trait + if err := s.db.WithContext(ctx).Order("name").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.Trait, len(values)) + for i, value := range values { + result[i] = servicemodel.Trait{ID: value.ID, Name: value.Name} + } + return result, nil +} + +func (s *Store) ListInvitations(ctx context.Context, jobID uint) ([]servicemodel.Invitation, error) { + var values []dbmodel.Invitation + if err := s.db.WithContext(ctx).Preload("Participant").Where("job_id = ?", jobID). + Order("created_at DESC").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.Invitation, len(values)) + for i, value := range values { + result[i] = dbmapper.InvitationToService(value) + } + return result, nil +} + +func (s *Store) CreateInvitation(ctx context.Context, value servicemodel.Invitation) (servicemodel.Invitation, error) { + entity := dbmodel.Invitation{ + JobID: value.JobID, ParticipantID: value.ParticipantID, + Token: value.Token, Status: value.Status, DurationMinutes: value.DurationMinutes, + } + if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { + return servicemodel.Invitation{}, mapError(err) + } + var created dbmodel.Invitation + if err := s.db.WithContext(ctx).Preload("Participant").First(&created, entity.ID).Error; err != nil { + return servicemodel.Invitation{}, mapError(err) + } + return dbmapper.InvitationToService(created), nil +} + +func (s *Store) GetInterview(ctx context.Context, token string) (servicemodel.Interview, error) { + var invitation dbmodel.Invitation + err := s.db.WithContext(ctx).Preload("Participant").Preload("Answers"). + Preload("Job.Labels").Preload("Job.RequiredSkills").Preload("Job.AdditionalSkills"). + Preload("Job.Questions.Options").Where("token = ?", token).First(&invitation).Error + if err != nil { + return servicemodel.Interview{}, mapError(err) + } + answers := make(map[uint]string, len(invitation.Answers)) + for _, answer := range invitation.Answers { + answers[answer.QuestionID] = answer.Answer + } + return servicemodel.Interview{ + Invitation: dbmapper.InvitationToService(invitation), + Job: dbmapper.JobToService(invitation.Job), + Answers: answers, + }, nil +} + +func (s *Store) AcceptInvitation(ctx context.Context, token string, acceptedAt time.Time) error { + result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). + Where("token = ? AND status = ?", token, "pending"). + Updates(map[string]any{"status": "accepted", "accepted_at": acceptedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrConflict + } + return nil +} + +func (s *Store) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error { + var invitation dbmodel.Invitation + if err := s.db.WithContext(ctx).Where("token = ?", token).First(&invitation).Error; err != nil { + return mapError(err) + } + value := dbmodel.InvitationAnswer{InvitationID: invitation.ID, QuestionID: questionID, Answer: answer} + return s.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "invitation_id"}, {Name: "question_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"answer", "updated_at"}), + }).Create(&value).Error +} + +func (s *Store) CompleteInvitation(ctx context.Context, token string, completedAt time.Time) error { + result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). + Where("token = ? AND status = ?", token, "accepted"). + Updates(map[string]any{"status": "completed", "completed_at": completedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrConflict + } + return nil +} + +func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) { + result := make([]dbmodel.Trait, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + name := strings.TrimSpace(value.Name) + normalized := strings.ToLower(name) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + var trait dbmodel.Trait + if err := s.db.WithContext(ctx).Where(dbmodel.Trait{Normalized: normalized}). + Attrs(dbmodel.Trait{Name: name}).FirstOrCreate(&trait).Error; err != nil { + return nil, err + } + result = append(result, trait) + } + return result, nil +} + +func preloadJob(db *gorm.DB) *gorm.DB { + return db.Preload("Labels").Preload("RequiredSkills").Preload("AdditionalSkills"). + Preload("Questions.Options") +} + +func mapError(err error) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) && pgError.Code == "23505" { + return ErrConflict + } + return err +} diff --git a/backend/internal/database/repository/store_integration_test.go b/backend/internal/database/repository/store_integration_test.go new file mode 100644 index 0000000..233f4a3 --- /dev/null +++ b/backend/internal/database/repository/store_integration_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package repository + +import ( + "context" + "os" + "testing" + + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "github.com/danyel/go-guess/backend/internal/service/model" +) + +func TestStoreEditsQuestionWithoutDeletingIt(t *testing.T) { + databaseURL := os.Getenv("TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + store := New(db) + created, err := store.CreateQuestion(context.Background(), model.Question{ + Text: "Original?", Type: "open", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Exec("DELETE FROM questions WHERE id = ?", created.ID) }) + + updated, err := store.UpdateQuestion(context.Background(), created.ID, model.Question{ + Text: "Choose two", Type: "multiple_choice", Options: []string{"One", "Two"}, + }) + if err != nil { + t.Fatal(err) + } + if updated.ID != created.ID || updated.Text != "Choose two" || len(updated.Options) != 2 { + t.Fatalf("unexpected updated question: %#v", updated) + } +} + +func TestStorePersistsJobWithTraits(t *testing.T) { + databaseURL := os.Getenv("TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not set") + } + db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + store := New(db) + suffix := uuid.NewString() + created, err := store.CreateJob(context.Background(), model.JobPosting{ + Title: "Integration " + suffix, Description: "Database mapping test", + Seniority: "Senior", Position: "Backend Engineer", + Labels: []model.Trait{{Name: "integration-" + suffix}}, + RequiredSkills: []model.Trait{{Name: "Go"}}, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Exec("DELETE FROM job_postings WHERE id = ?", created.ID) }) + + found, err := store.GetJob(context.Background(), created.ID) + if err != nil { + t.Fatal(err) + } + if found.Title != created.Title || len(found.Labels) != 1 || len(found.RequiredSkills) != 1 { + t.Fatalf("unexpected stored job: %#v", found) + } +} diff --git a/backend/internal/security/security.go b/backend/internal/security/security.go new file mode 100644 index 0000000..ce2f416 --- /dev/null +++ b/backend/internal/security/security.go @@ -0,0 +1,75 @@ +package security + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var ErrInvalidToken = errors.New("invalid token") + +type Claims struct { + UserID uint `json:"userId"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type ITokenManager interface { + Issue(uint, string, string) (string, error) + Parse(string) (Claims, error) +} + +type TokenManager struct { + secret []byte + ttl time.Duration + now func() time.Time +} + +func NewTokenManager(secret string, ttl time.Duration) *TokenManager { + return &TokenManager{secret: []byte(secret), ttl: ttl, now: time.Now} +} + +func (m *TokenManager) Issue(userID uint, email, role string) (string, error) { + now := m.now() + claims := Claims{ + UserID: userID, Email: email, Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: fmt.Sprint(userID), IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)), + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret) +} + +func (m *TokenManager) Parse(raw string) (Claims, error) { + token, err := jwt.ParseWithClaims(raw, &Claims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, ErrInvalidToken + } + return m.secret, nil + }) + if err != nil || !token.Valid { + return Claims{}, ErrInvalidToken + } + claims, ok := token.Claims.(*Claims) + if !ok { + return Claims{}, ErrInvalidToken + } + return *claims, nil +} + +func HashPassword(password string) (string, error) { + value, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hash password: %w", err) + } + return string(value), nil +} + +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/backend/internal/security/security_test.go b/backend/internal/security/security_test.go new file mode 100644 index 0000000..2b2bd16 --- /dev/null +++ b/backend/internal/security/security_test.go @@ -0,0 +1,33 @@ +package security + +import ( + "testing" + "time" +) + +func TestTokenRoundTrip(t *testing.T) { + manager := NewTokenManager("01234567890123456789012345678901", time.Hour) + raw, err := manager.Issue(42, "interviewer@example.com", "interviewer") + if err != nil { + t.Fatal(err) + } + claims, err := manager.Parse(raw) + if err != nil { + t.Fatal(err) + } + if claims.UserID != 42 || claims.Email != "interviewer@example.com" || claims.Role != "interviewer" { + t.Fatalf("unexpected claims: %#v", claims) + } +} + +func TestRejectsTokenSignedWithAnotherSecret(t *testing.T) { + issuer := NewTokenManager("01234567890123456789012345678901", time.Hour) + verifier := NewTokenManager("abcdefghijklmnopqrstuvwxyz123456", time.Hour) + raw, err := issuer.Issue(1, "user@example.com", "interviewer") + if err != nil { + t.Fatal(err) + } + if _, err := verifier.Parse(raw); err == nil { + t.Fatal("expected token validation to fail") + } +} diff --git a/backend/internal/service/cv.go b/backend/internal/service/cv.go new file mode 100644 index 0000000..14c660c --- /dev/null +++ b/backend/internal/service/cv.go @@ -0,0 +1,78 @@ +package service + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/ledongthuc/pdf" +) + +func extractCVText(filename string, content []byte) (text string, err error) { + if len(content) == 0 { + return "", nil + } + switch strings.ToLower(filepath.Ext(filename)) { + case ".pdf": + defer func() { + if recovered := recover(); recovered != nil { + text = "" + err = fmt.Errorf("unreadable PDF") + } + }() + reader, err := pdf.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return "", fmt.Errorf("read PDF: %w", err) + } + plainText, err := reader.GetPlainText() + if err != nil { + return "", fmt.Errorf("extract PDF text: %w", err) + } + value, err := io.ReadAll(plainText) + if err != nil { + return "", fmt.Errorf("read PDF text: %w", err) + } + return string(value), nil + case ".docx": + return extractDOCX(content) + default: + return string(content), nil + } +} + +func extractDOCX(content []byte) (string, error) { + archive, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) + if err != nil { + return "", fmt.Errorf("open DOCX: %w", err) + } + for _, file := range archive.File { + if file.Name != "word/document.xml" { + continue + } + stream, err := file.Open() + if err != nil { + return "", fmt.Errorf("open DOCX document: %w", err) + } + defer stream.Close() + decoder := xml.NewDecoder(stream) + var text strings.Builder + for { + token, err := decoder.Token() + if err == io.EOF { + return text.String(), nil + } + if err != nil { + return "", fmt.Errorf("parse DOCX document: %w", err) + } + if chars, ok := token.(xml.CharData); ok { + text.Write(chars) + text.WriteByte(' ') + } + } + } + return "", fmt.Errorf("DOCX document body is missing") +} diff --git a/backend/internal/service/cv_test.go b/backend/internal/service/cv_test.go new file mode 100644 index 0000000..8354487 --- /dev/null +++ b/backend/internal/service/cv_test.go @@ -0,0 +1,30 @@ +package service + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestExtractCVTextFromDOCX(t *testing.T) { + var content bytes.Buffer + archive := zip.NewWriter(&content) + document, err := archive.Create("word/document.xml") + if err != nil { + t.Fatal(err) + } + if _, err := document.Write([]byte(`Go and PostgreSQL`)); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + + text, err := extractCVText("candidate.docx", content.Bytes()) + if err != nil { + t.Fatal(err) + } + if text != "Go and PostgreSQL " { + t.Fatalf("unexpected extracted text %q", text) + } +} diff --git a/backend/internal/service/model/models.go b/backend/internal/service/model/models.go new file mode 100644 index 0000000..dee0e7f --- /dev/null +++ b/backend/internal/service/model/models.go @@ -0,0 +1,81 @@ +package model + +import "time" + +type User struct { + ID uint + Email string + PasswordHash string + Role string +} + +type Trait struct { + ID uint + Name string +} + +type JobPosting struct { + ID uint + Title string + Description string + Seniority string + Position string + Status string + DurationMinutes int + Labels []Trait + RequiredSkills []Trait + AdditionalSkills []Trait + Questions []Question + CreatedAt time.Time +} + +type Question struct { + ID uint + Text string + Type string + Options []string + ReferenceAnswer string + Deprecated bool +} + +type Participant struct { + ID uint + FirstName string + LastName string + Birthday *time.Time + Email string + ContactInfo string + Photo []byte + PhotoType string + CV []byte + CVFilename string + Traits []Trait + CreatedAt time.Time +} + +type CandidateMatch struct { + Participant Participant + Score float64 + Matched []string + Missing []string +} + +type Invitation struct { + ID uint + JobID uint + ParticipantID uint + ParticipantName string + ParticipantEmail string + Token string + Status string + DurationMinutes int + AcceptedAt *time.Time + CompletedAt *time.Time + CreatedAt time.Time +} + +type Interview struct { + Invitation Invitation + Job JobPosting + Answers map[uint]string +} diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go new file mode 100644 index 0000000..0b8b155 --- /dev/null +++ b/backend/internal/service/services.go @@ -0,0 +1,454 @@ +package service + +import ( + "context" + "errors" + "fmt" + "math" + "sort" + "strings" + "time" + "unicode" + + "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service/model" + "github.com/google/uuid" +) + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrInvalidQuestion = errors.New("invalid question") + ErrValidation = errors.New("validation failed") + ErrInvalidState = errors.New("operation is not allowed in the current state") +) + +const CandidateThreshold = 60.0 + +type IAuthService interface { + Login(context.Context, string, string) (string, model.User, error) +} + +type IJobService interface { + List(context.Context) ([]model.JobPosting, error) + Get(context.Context, uint) (model.JobPosting, error) + Create(context.Context, model.JobPosting) (model.JobPosting, error) + Update(context.Context, uint, string, int) (model.JobPosting, error) + AttachQuestion(context.Context, uint, uint) error + DetachQuestion(context.Context, uint, uint) error + Candidates(context.Context, uint) ([]model.CandidateMatch, error) +} + +type IQuestionService interface { + List(context.Context, string) ([]model.Question, error) + Create(context.Context, model.Question) (model.Question, error) + Update(context.Context, uint, model.Question) (model.Question, error) + SetDeprecated(context.Context, uint, bool) (model.Question, error) +} + +type IParticipantService interface { + List(context.Context) ([]model.Participant, error) + Get(context.Context, uint) (model.Participant, error) + Create(context.Context, model.Participant) (model.Participant, error) +} + +type IInvitationService interface { + List(context.Context, uint) ([]model.Invitation, error) + Create(context.Context, uint, uint) (model.Invitation, error) + GetInterview(context.Context, string) (model.Interview, error) + Accept(context.Context, string) (model.Interview, error) + SaveAnswer(context.Context, string, uint, string) error + Finish(context.Context, string) (model.Interview, error) +} + +type AuthService struct { + store repository.IStore + tokens security.ITokenManager +} + +func NewAuthService(store repository.IStore, tokens security.ITokenManager) *AuthService { + return &AuthService{store: store, tokens: tokens} +} + +func (s *AuthService) Login(ctx context.Context, email, password string) (string, model.User, error) { + user, err := s.store.FindUserByEmail(ctx, strings.TrimSpace(email)) + if err != nil || !security.CheckPassword(user.PasswordHash, password) { + return "", model.User{}, ErrInvalidCredentials + } + token, err := s.tokens.Issue(user.ID, user.Email, user.Role) + if err != nil { + return "", model.User{}, err + } + user.PasswordHash = "" + return token, user, nil +} + +type JobService struct { + store repository.IStore +} + +func NewJobService(store repository.IStore) *JobService { + return &JobService{store: store} +} + +func (s *JobService) List(ctx context.Context) ([]model.JobPosting, error) { + return s.store.ListJobs(ctx) +} + +func (s *JobService) Get(ctx context.Context, id uint) (model.JobPosting, error) { + return s.store.GetJob(ctx, id) +} + +func (s *JobService) Create(ctx context.Context, value model.JobPosting) (model.JobPosting, error) { + value.Title = strings.TrimSpace(value.Title) + value.Description = strings.TrimSpace(value.Description) + value.Position = strings.TrimSpace(value.Position) + value.Seniority = strings.TrimSpace(value.Seniority) + if value.Title == "" || value.Description == "" || value.Position == "" || value.Seniority == "" { + return model.JobPosting{}, fmt.Errorf("%w: title, description, position, and seniority are required", ErrValidation) + } + value.Status = "draft" + if value.DurationMinutes == 0 { + value.DurationMinutes = 60 + } + if value.DurationMinutes < 1 || value.DurationMinutes > 480 { + return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation) + } + return s.store.CreateJob(ctx, value) +} + +func (s *JobService) Update(ctx context.Context, id uint, status string, durationMinutes int) (model.JobPosting, error) { + if !validJobStatus(status) { + return model.JobPosting{}, fmt.Errorf("%w: unsupported job status", ErrValidation) + } + if durationMinutes < 1 || durationMinutes > 480 { + return model.JobPosting{}, fmt.Errorf("%w: duration must be between 1 and 480 minutes", ErrValidation) + } + if status == "published" { + job, err := s.store.GetJob(ctx, id) + if err != nil { + return model.JobPosting{}, err + } + if len(job.Questions) == 0 { + return model.JobPosting{}, fmt.Errorf("%w: a published job requires at least one question", ErrInvalidState) + } + } + return s.store.UpdateJob(ctx, id, status, durationMinutes) +} + +func (s *JobService) AttachQuestion(ctx context.Context, jobID, questionID uint) error { + question, err := s.store.GetQuestion(ctx, questionID) + if err != nil { + return err + } + if question.Deprecated { + return fmt.Errorf("%w: deprecated questions cannot be newly attached", ErrInvalidState) + } + return s.store.AttachQuestion(ctx, jobID, questionID) +} + +func (s *JobService) DetachQuestion(ctx context.Context, jobID, questionID uint) error { + return s.store.DetachQuestion(ctx, jobID, questionID) +} + +type QuestionService struct { + store repository.IStore +} + +func NewQuestionService(store repository.IStore) *QuestionService { + return &QuestionService{store: store} +} + +func (s *QuestionService) List(ctx context.Context, search string) ([]model.Question, error) { + return s.store.ListQuestions(ctx, search) +} + +func (s *QuestionService) Create(ctx context.Context, value model.Question) (model.Question, error) { + value, err := validateQuestion(value) + if err != nil { + return model.Question{}, err + } + return s.store.CreateQuestion(ctx, value) +} + +func (s *QuestionService) Update(ctx context.Context, id uint, value model.Question) (model.Question, error) { + value, err := validateQuestion(value) + if err != nil { + return model.Question{}, err + } + return s.store.UpdateQuestion(ctx, id, value) +} + +func validateQuestion(value model.Question) (model.Question, error) { + value.Text = strings.TrimSpace(value.Text) + value.ReferenceAnswer = strings.TrimSpace(value.ReferenceAnswer) + switch value.Type { + case "open", "multiple_choice", "radio", "code_review": + default: + return model.Question{}, fmt.Errorf("%w: unsupported type", ErrInvalidQuestion) + } + if value.Text == "" { + return model.Question{}, fmt.Errorf("%w: text is required", ErrInvalidQuestion) + } + options := make([]string, 0, len(value.Options)) + for _, option := range value.Options { + if option = strings.TrimSpace(option); option != "" { + options = append(options, option) + } + } + value.Options = options + if (value.Type == "multiple_choice" || value.Type == "radio") && len(value.Options) < 2 { + return model.Question{}, fmt.Errorf("%w: at least two options are required", ErrInvalidQuestion) + } + if (value.Type == "open" || value.Type == "code_review") && value.ReferenceAnswer == "" { + return model.Question{}, fmt.Errorf("%w: a reference answer is required", ErrInvalidQuestion) + } + if value.Type == "open" || value.Type == "code_review" { + value.Options = nil + } else { + value.ReferenceAnswer = "" + } + return value, nil +} + +func (s *QuestionService) SetDeprecated(ctx context.Context, id uint, deprecated bool) (model.Question, error) { + return s.store.SetQuestionDeprecated(ctx, id, deprecated) +} + +func (s *JobService) Candidates(ctx context.Context, jobID uint) ([]model.CandidateMatch, error) { + job, err := s.store.GetJob(ctx, jobID) + if err != nil { + return nil, err + } + participants, err := s.store.ListParticipants(ctx) + if err != nil { + return nil, err + } + required := uniqueTraits(job.Labels, job.RequiredSkills, job.AdditionalSkills) + if len(required) == 0 { + return []model.CandidateMatch{}, nil + } + result := make([]model.CandidateMatch, 0) + for _, participant := range participants { + owned := make(map[string]struct{}, len(participant.Traits)) + for _, trait := range participant.Traits { + owned[normalize(trait.Name)] = struct{}{} + } + text, err := extractCVText(participant.CVFilename, participant.CV) + if err != nil { + return nil, fmt.Errorf("extract participant %d CV text: %w", participant.ID, err) + } + for name := range required { + if containsTerm(normalize(text), name) { + owned[name] = struct{}{} + } + } + match := model.CandidateMatch{Participant: participant} + for name, display := range required { + if _, ok := owned[name]; ok { + match.Matched = append(match.Matched, display) + } else { + match.Missing = append(match.Missing, display) + } + } + match.Score = math.Round(float64(len(match.Matched))/float64(len(required))*1000) / 10 + if match.Score >= CandidateThreshold { + sort.Strings(match.Matched) + sort.Strings(match.Missing) + result = append(result, match) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Score > result[j].Score }) + return result, nil +} + +type ParticipantService struct { + store repository.IStore +} + +func NewParticipantService(store repository.IStore) *ParticipantService { + return &ParticipantService{store: store} +} + +func (s *ParticipantService) List(ctx context.Context) ([]model.Participant, error) { + return s.store.ListParticipants(ctx) +} + +func (s *ParticipantService) Get(ctx context.Context, id uint) (model.Participant, error) { + return s.store.GetParticipant(ctx, id) +} + +func (s *ParticipantService) Create(ctx context.Context, value model.Participant) (model.Participant, error) { + value.FirstName = strings.TrimSpace(value.FirstName) + value.LastName = strings.TrimSpace(value.LastName) + value.Email = strings.ToLower(strings.TrimSpace(value.Email)) + if value.FirstName == "" || value.LastName == "" || value.Email == "" { + return model.Participant{}, fmt.Errorf("%w: first name, last name, and email are required", ErrValidation) + } + allTraits, err := s.store.AllTraits(ctx) + if err != nil { + return model.Participant{}, err + } + text, err := extractCVText(value.CVFilename, value.CV) + if err != nil { + return model.Participant{}, fmt.Errorf("%w: extract CV text: %v", ErrValidation, err) + } + value.Traits = extractTraits(text, allTraits) + return s.store.CreateParticipant(ctx, value) +} + +func extractTraits(content string, traits []model.Trait) []model.Trait { + text := normalize(content) + result := make([]model.Trait, 0) + for _, trait := range traits { + name := normalize(trait.Name) + if name != "" && containsTerm(text, name) { + result = append(result, trait) + } + } + return result +} + +func containsTerm(text, term string) bool { + return strings.Contains(" "+text+" ", " "+term+" ") +} + +func uniqueTraits(groups ...[]model.Trait) map[string]string { + result := make(map[string]string) + for _, group := range groups { + for _, trait := range group { + if name := normalize(trait.Name); name != "" { + result[name] = trait.Name + } + } + } + return result +} + +func normalize(value string) string { + return strings.Join(strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '+' && r != '#' + }), " ") +} + +type InvitationService struct { + store repository.IStore + frontendURL string + now func() time.Time +} + +func NewInvitationService(store repository.IStore, frontendURL string) *InvitationService { + return &InvitationService{store: store, frontendURL: strings.TrimRight(frontendURL, "/"), now: time.Now} +} + +func (s *InvitationService) List(ctx context.Context, jobID uint) ([]model.Invitation, error) { + return s.store.ListInvitations(ctx, jobID) +} + +func (s *InvitationService) Create(ctx context.Context, jobID, participantID uint) (model.Invitation, error) { + job, err := s.store.GetJob(ctx, jobID) + if err != nil { + return model.Invitation{}, err + } + if job.Status != "published" { + return model.Invitation{}, fmt.Errorf("%w: invitations require a published job", ErrInvalidState) + } + if _, err := s.store.GetParticipant(ctx, participantID); err != nil { + return model.Invitation{}, err + } + matches, err := NewJobService(s.store).Candidates(ctx, jobID) + if err != nil { + return model.Invitation{}, err + } + eligible := false + for _, match := range matches { + if match.Participant.ID == participantID { + eligible = true + break + } + } + if !eligible { + return model.Invitation{}, fmt.Errorf("%w: participant does not meet the %.0f%% threshold", ErrInvalidState, CandidateThreshold) + } + return s.store.CreateInvitation(ctx, model.Invitation{ + JobID: jobID, ParticipantID: participantID, Token: uuid.NewString(), + Status: "pending", DurationMinutes: job.DurationMinutes, + }) +} + +func (s *InvitationService) GetInterview(ctx context.Context, token string) (model.Interview, error) { + interview, err := s.store.GetInterview(ctx, token) + if err != nil { + return model.Interview{}, err + } + if interview.Job.Status != "published" && interview.Invitation.Status == "pending" { + return model.Interview{}, ErrNotAvailable + } + interview.Job.DurationMinutes = interview.Invitation.DurationMinutes + return interview, nil +} + +var ErrNotAvailable = errors.New("interview is not available") + +func (s *InvitationService) Accept(ctx context.Context, token string) (model.Interview, error) { + interview, err := s.GetInterview(ctx, token) + if err != nil { + return model.Interview{}, err + } + if interview.Invitation.Status != "pending" { + return model.Interview{}, ErrInvalidState + } + if err := s.store.AcceptInvitation(ctx, token, s.now()); err != nil { + return model.Interview{}, err + } + return s.GetInterview(ctx, token) +} + +func (s *InvitationService) SaveAnswer(ctx context.Context, token string, questionID uint, answer string) error { + interview, err := s.GetInterview(ctx, token) + if err != nil { + return err + } + if interview.Invitation.Status != "accepted" || interviewExpired(interview, s.now()) { + return ErrInvalidState + } + found := false + for _, question := range interview.Job.Questions { + if question.ID == questionID { + found = true + break + } + } + if !found { + return repository.ErrNotFound + } + return s.store.SaveAnswer(ctx, token, questionID, strings.TrimSpace(answer)) +} + +func (s *InvitationService) Finish(ctx context.Context, token string) (model.Interview, error) { + interview, err := s.GetInterview(ctx, token) + if err != nil { + return model.Interview{}, err + } + if interview.Invitation.Status != "accepted" { + return model.Interview{}, ErrInvalidState + } + if err := s.store.CompleteInvitation(ctx, token, s.now()); err != nil { + return model.Interview{}, err + } + return s.GetInterview(ctx, token) +} + +func interviewExpired(interview model.Interview, now time.Time) bool { + return interview.Invitation.AcceptedAt != nil && + !now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute)) +} + +func validJobStatus(status string) bool { + switch status { + case "draft", "published", "deprecated": + return true + default: + return false + } +} diff --git a/backend/internal/service/services_test.go b/backend/internal/service/services_test.go new file mode 100644 index 0000000..197afae --- /dev/null +++ b/backend/internal/service/services_test.go @@ -0,0 +1,182 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/danyel/go-guess/backend/internal/service/model" +) + +type fakeStore struct { + job model.JobPosting + participants []model.Participant + traits []model.Trait +} + +type invitationStore struct { + *fakeStore + interview model.Interview + saved bool +} + +func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) { + value.ID = 1 + return value, nil +} +func (f *invitationStore) GetInterview(context.Context, string) (model.Interview, error) { + return f.interview, nil +} +func (f *invitationStore) SaveAnswer(context.Context, string, uint, string) error { + f.saved = true + return nil +} + +func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) { + return model.User{}, errors.New("unused") +} +func (f *fakeStore) ListJobs(context.Context) ([]model.JobPosting, error) { return nil, nil } +func (f *fakeStore) GetJob(context.Context, uint) (model.JobPosting, error) { return f.job, nil } +func (f *fakeStore) CreateJob(_ context.Context, value model.JobPosting) (model.JobPosting, error) { + return value, nil +} +func (f *fakeStore) CreateQuestion(_ context.Context, value model.Question) (model.Question, error) { + return value, nil +} +func (f *fakeStore) UpdateQuestion(_ context.Context, _ uint, value model.Question) (model.Question, error) { + return value, nil +} +func (f *fakeStore) ListParticipants(context.Context) ([]model.Participant, error) { + return f.participants, nil +} +func (f *fakeStore) GetParticipant(context.Context, uint) (model.Participant, error) { + return model.Participant{}, nil +} +func (f *fakeStore) CreateParticipant(_ context.Context, value model.Participant) (model.Participant, error) { + return value, nil +} +func (f *fakeStore) AllTraits(context.Context) ([]model.Trait, error) { return f.traits, nil } +func (f *fakeStore) UpdateJob(_ context.Context, _ uint, _ string, _ int) (model.JobPosting, error) { + return f.job, nil +} +func (f *fakeStore) ListQuestions(context.Context, string) ([]model.Question, error) { + return nil, nil +} +func (f *fakeStore) GetQuestion(context.Context, uint) (model.Question, error) { + return model.Question{}, nil +} +func (f *fakeStore) SetQuestionDeprecated(_ context.Context, _ uint, _ bool) (model.Question, error) { + return model.Question{}, nil +} +func (f *fakeStore) AttachQuestion(context.Context, uint, uint) error { return nil } +func (f *fakeStore) DetachQuestion(context.Context, uint, uint) error { return nil } +func (f *fakeStore) ListInvitations(context.Context, uint) ([]model.Invitation, error) { + return nil, nil +} +func (f *fakeStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) { + return value, nil +} +func (f *fakeStore) GetInterview(context.Context, string) (model.Interview, error) { + return model.Interview{}, nil +} +func (f *fakeStore) AcceptInvitation(context.Context, string, time.Time) error { return nil } +func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error { return nil } +func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error { + return nil +} + +func TestCandidatesRequireSixtyPercent(t *testing.T) { + store := &fakeStore{ + job: model.JobPosting{ + Labels: []model.Trait{{Name: "Backend"}}, + RequiredSkills: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}, {Name: "React"}}, + }, + participants: []model.Participant{ + {ID: 1, Traits: []model.Trait{{Name: "Go"}, {Name: "PostgreSQL"}, {Name: "Docker"}}}, + {ID: 2, Traits: []model.Trait{{Name: "Go"}, {Name: "React"}}}, + }, + } + + matches, err := NewJobService(store).Candidates(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].Participant.ID != 1 || matches[0].Score != 60 { + t.Fatalf("expected only participant 1 at 60%%, got %#v", matches) + } +} + +func TestParticipantCreateExtractsKnownTraits(t *testing.T) { + store := &fakeStore{traits: []model.Trait{{Name: "Go"}, {Name: "React"}, {Name: "C++"}}} + value, err := NewParticipantService(store).Create(context.Background(), model.Participant{ + FirstName: "Ada", LastName: "Lovelace", Email: "ADA@EXAMPLE.COM", + CV: []byte("Backend engineer using Go and C++ daily."), CVFilename: "cv.txt", + }) + if err != nil { + t.Fatal(err) + } + if value.Email != "ada@example.com" || len(value.Traits) != 2 { + t.Fatalf("unexpected participant: %#v", value) + } +} + +func TestInvitationRequiresPublishedMatchingJob(t *testing.T) { + store := &invitationStore{fakeStore: &fakeStore{ + job: model.JobPosting{ + ID: 1, Status: "published", + RequiredSkills: []model.Trait{{Name: "Go"}}, + }, + participants: []model.Participant{{ID: 7, Traits: []model.Trait{{Name: "Go"}}}}, + }} + invitation, err := NewInvitationService(store, "http://localhost:5173").Create(context.Background(), 1, 7) + if err != nil { + t.Fatal(err) + } + if invitation.Status != "pending" || invitation.Token == "" { + t.Fatalf("unexpected invitation: %#v", invitation) + } + + store.job.Status = "draft" + if _, err := NewInvitationService(store, "").Create(context.Background(), 1, 7); !errors.Is(err, ErrInvalidState) { + t.Fatalf("expected invalid state for draft job, got %v", err) + } +} + +func TestExpiredInterviewRejectsAnswers(t *testing.T) { + acceptedAt := time.Now().Add(-61 * time.Minute) + store := &invitationStore{ + fakeStore: &fakeStore{}, + interview: model.Interview{ + Invitation: model.Invitation{Status: "accepted", AcceptedAt: &acceptedAt}, + Job: model.JobPosting{ + Status: "published", DurationMinutes: 60, + Questions: []model.Question{{ID: 9}}, + }, + }, + } + err := NewInvitationService(store, "").SaveAnswer(context.Background(), "token", 9, "answer") + if !errors.Is(err, ErrInvalidState) || store.saved { + t.Fatalf("expected expired answer to be rejected, got %v", err) + } +} + +func TestQuestionReferenceAnswerValidation(t *testing.T) { + service := NewQuestionService(&fakeStore{}) + if _, err := service.Create(context.Background(), model.Question{ + Text: "Explain cancellation", Type: "open", + }); !errors.Is(err, ErrInvalidQuestion) { + t.Fatalf("expected missing reference answer error, got %v", err) + } + + question, err := service.Create(context.Background(), model.Question{ + Text: "Choose", Type: "radio", Options: []string{" One ", "Two"}, + ReferenceAnswer: "must be cleared", + }) + if err != nil { + t.Fatal(err) + } + if question.ReferenceAnswer != "" || question.Options[0] != "One" { + t.Fatalf("unexpected normalized question: %#v", question) + } +} diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go new file mode 100644 index 0000000..13bc2fc --- /dev/null +++ b/backend/internal/web/handler/handler.go @@ -0,0 +1,438 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/service" + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" + webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" + webmodel "github.com/danyel/go-guess/backend/internal/web/model" +) + +type Handler struct { + auth service.IAuthService + jobs service.IJobService + questions service.IQuestionService + participants service.IParticipantService + invitations service.IInvitationService + maxUpload int64 + frontendURL string +} + +func New( + auth service.IAuthService, + jobs service.IJobService, + questions service.IQuestionService, + participants service.IParticipantService, + invitations service.IInvitationService, + maxUploadMB int64, + frontendURL string, +) *Handler { + return &Handler{ + auth: auth, jobs: jobs, questions: questions, participants: participants, + invitations: invitations, maxUpload: maxUploadMB << 20, + frontendURL: strings.TrimRight(frontendURL, "/"), + } +} + +func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var request webmodel.LoginRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + token, user, err := h.auth.Login(r.Context(), request.Email, request.Password) + if err != nil { + if errors.Is(err, service.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, err) + return + } + internalError(w, r, err) + return + } + writeJSON(w, http.StatusOK, webmodel.LoginResponse{ + Token: token, User: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role}, + }) +} + +func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) { + values, err := h.jobs.List(r.Context()) + if err != nil { + internalError(w, r, err) + return + } + response := make([]webmodel.JobResponse, len(values)) + for i, value := range values { + response[i] = webmapper.JobToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetJob(w http.ResponseWriter, r *http.Request) { + value, err := h.jobs.Get(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.JobToWeb(value)) +} + +func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { + var request webmodel.JobRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.jobs.Create(r.Context(), webmapper.JobToService(request)) + if err != nil { + handleServiceError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, webmapper.JobToWeb(value)) +} + +func (h *Handler) UpdateJob(w http.ResponseWriter, r *http.Request) { + var request webmodel.JobStatusRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.jobs.Update(r.Context(), idParam(r), request.Status, request.DurationMinutes) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.JobToWeb(value)) +} + +func (h *Handler) ListQuestions(w http.ResponseWriter, r *http.Request) { + values, err := h.questions.List(r.Context(), r.URL.Query().Get("search")) + if err != nil { + internalError(w, r, err) + return + } + response := make([]webmodel.QuestionResponse, len(values)) + for i, value := range values { + response[i] = webmapper.QuestionToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) CreateQuestion(w http.ResponseWriter, r *http.Request) { + var request webmodel.QuestionRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.questions.Create(r.Context(), webmapper.QuestionToService(request)) + if err != nil { + handleServiceError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, webmapper.QuestionToWeb(value)) +} + +func (h *Handler) UpdateQuestion(w http.ResponseWriter, r *http.Request) { + var request webmodel.QuestionRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.questions.Update(r.Context(), idParam(r), webmapper.QuestionToService(request)) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value)) +} + +func (h *Handler) SetQuestionDeprecated(w http.ResponseWriter, r *http.Request) { + var request webmodel.QuestionStatusRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.questions.SetDeprecated(r.Context(), idParam(r), request.Deprecated) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.QuestionToWeb(value)) +} + +func (h *Handler) AttachQuestion(w http.ResponseWriter, r *http.Request) { + if err := h.jobs.AttachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) { + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) DetachQuestion(w http.ResponseWriter, r *http.Request) { + if err := h.jobs.DetachQuestion(r.Context(), idParam(r), secondaryIDParam(r, "questionId")); handleServiceError(w, r, err) { + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) Candidates(w http.ResponseWriter, r *http.Request) { + values, err := h.jobs.Candidates(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + response := make([]webmodel.CandidateResponse, len(values)) + for i, value := range values { + response[i] = webmapper.CandidateToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) ListParticipants(w http.ResponseWriter, r *http.Request) { + values, err := h.participants.List(r.Context()) + if err != nil { + internalError(w, r, err) + return + } + response := make([]webmodel.ParticipantResponse, len(values)) + for i, value := range values { + response[i] = webmapper.ParticipantToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetParticipant(w http.ResponseWriter, r *http.Request) { + value, err := h.participants.Get(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ParticipantToWeb(value)) +} + +func (h *Handler) CreateParticipant(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, h.maxUpload) + if err := r.ParseMultipartForm(h.maxUpload); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid multipart form or upload too large")) + return + } + participant := servicemodel.Participant{ + FirstName: r.FormValue("firstName"), LastName: r.FormValue("lastName"), + Email: r.FormValue("email"), ContactInfo: r.FormValue("contactInfo"), + } + if raw := r.FormValue("birthday"); raw != "" { + birthday, err := time.Parse("2006-01-02", raw) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("birthday must use YYYY-MM-DD")) + return + } + participant.Birthday = &birthday + } + var err error + participant.Photo, participant.PhotoType, _, err = readUpload(r, "photo") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + participant.CV, _, participant.CVFilename, err = readUpload(r, "cv") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.participants.Create(r.Context(), participant) + if err != nil { + handleServiceError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, webmapper.ParticipantToWeb(value)) +} + +func (h *Handler) ParticipantPhoto(w http.ResponseWriter, r *http.Request) { + value, err := h.participants.Get(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + if len(value.Photo) == 0 { + http.NotFound(w, r) + return + } + contentType := value.PhotoType + switch contentType { + case "image/jpeg", "image/png", "image/gif", "image/webp": + default: + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", "inline") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(value.Photo) +} + +func (h *Handler) ParticipantCV(w http.ResponseWriter, r *http.Request) { + value, err := h.participants.Get(r.Context(), idParam(r)) + if handleServiceError(w, r, err) { + return + } + if len(value.CV) == 0 { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", value.CVFilename)) + _, _ = w.Write(value.CV) +} + +func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) { + values, err := h.invitations.List(r.Context(), idParam(r)) + if err != nil { + internalError(w, r, err) + return + } + response := make([]webmodel.InvitationResponse, len(values)) + for i, value := range values { + response[i] = webmapper.InvitationToWeb(value, h.frontendURL) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { + var request webmodel.InvitationRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.invitations.Create(r.Context(), idParam(r), request.ParticipantID) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusCreated, webmapper.InvitationToWeb(value, h.frontendURL)) +} + +func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) { + value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token")) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) AcceptInterview(w http.ResponseWriter, r *http.Request) { + value, err := h.invitations.Accept(r.Context(), chi.URLParam(r, "token")) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) SaveAnswer(w http.ResponseWriter, r *http.Request) { + var request webmodel.AnswerRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + err := h.invitations.SaveAnswer( + r.Context(), chi.URLParam(r, "token"), secondaryIDParam(r, "questionId"), request.Answer, + ) + if handleServiceError(w, r, err) { + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) FinishInterview(w http.ResponseWriter, r *http.Request) { + value, err := h.invitations.Finish(r.Context(), chi.URLParam(r, "token")) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.InterviewToWeb(value, h.frontendURL)) +} + +func readUpload(r *http.Request, field string) ([]byte, string, string, error) { + file, header, err := r.FormFile(field) + if errors.Is(err, http.ErrMissingFile) { + return nil, "", "", nil + } + if err != nil { + return nil, "", "", fmt.Errorf("read %s: %w", field, err) + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, "", "", fmt.Errorf("read %s: %w", field, err) + } + return content, header.Header.Get("Content-Type"), safeFilename(header), nil +} + +func safeFilename(header *multipart.FileHeader) string { + return strings.ReplaceAll(strings.ReplaceAll(header.Filename, "/", "_"), "\\", "_") +} + +func idParam(r *http.Request) uint { + id, _ := strconv.ParseUint(chi.URLParam(r, "id"), 10, 64) + return uint(id) +} + +func secondaryIDParam(r *http.Request, name string) uint { + id, _ := strconv.ParseUint(chi.URLParam(r, name), 10, 64) + return uint(id) +} + +func decodeJSON(r *http.Request, target any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("invalid request body: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return fmt.Errorf("request body must contain one JSON value") + } + return nil +} + +func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool { + if err == nil { + return false + } + + switch { + case errors.Is(err, repository.ErrNotFound): + writeError(w, http.StatusNotFound, err) + case errors.Is(err, repository.ErrConflict): + writeError(w, http.StatusConflict, err) + case errors.Is(err, service.ErrValidation), errors.Is(err, service.ErrInvalidQuestion): + writeError(w, http.StatusBadRequest, err) + case errors.Is(err, service.ErrInvalidState): + writeError(w, http.StatusConflict, err) + case errors.Is(err, service.ErrNotAvailable): + writeError(w, http.StatusNotFound, err) + default: + internalError(w, r, err) + } + return true +} + +func internalError(w http.ResponseWriter, r *http.Request, err error) { + slog.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "error", err) + writeError(w, http.StatusInternalServerError, errors.New("internal server error")) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, webmodel.ErrorResponse{Error: err.Error()}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Error("encode response", "error", err) + } +} diff --git a/backend/internal/web/mapper/mapper.go b/backend/internal/web/mapper/mapper.go new file mode 100644 index 0000000..f871980 --- /dev/null +++ b/backend/internal/web/mapper/mapper.go @@ -0,0 +1,116 @@ +package mapper + +import ( + "fmt" + + servicemodel "github.com/danyel/go-guess/backend/internal/service/model" + webmodel "github.com/danyel/go-guess/backend/internal/web/model" +) + +func JobToService(value webmodel.JobRequest) servicemodel.JobPosting { + return servicemodel.JobPosting{ + Title: value.Title, Description: value.Description, Seniority: value.Seniority, + Position: value.Position, DurationMinutes: value.DurationMinutes, + Labels: traitsToService(value.Labels), RequiredSkills: traitsToService(value.RequiredSkills), + AdditionalSkills: traitsToService(value.AdditionalSkills), + } +} + +func QuestionToService(value webmodel.QuestionRequest) servicemodel.Question { + return servicemodel.Question{ + Text: value.Text, Type: value.Type, Options: value.Options, + ReferenceAnswer: value.ReferenceAnswer, + } +} + +func JobToWeb(value servicemodel.JobPosting) webmodel.JobResponse { + questions := make([]webmodel.QuestionResponse, len(value.Questions)) + for i, question := range value.Questions { + questions[i] = QuestionToWeb(question) + } + return webmodel.JobResponse{ + ID: value.ID, Title: value.Title, Description: value.Description, + Seniority: value.Seniority, Position: value.Position, Status: value.Status, + DurationMinutes: value.DurationMinutes, + Labels: traitsToWeb(value.Labels), RequiredSkills: traitsToWeb(value.RequiredSkills), + AdditionalSkills: traitsToWeb(value.AdditionalSkills), Questions: questions, + CreatedAt: value.CreatedAt, + } +} + +func QuestionToWeb(value servicemodel.Question) webmodel.QuestionResponse { + return webmodel.QuestionResponse{ + ID: value.ID, Text: value.Text, Type: value.Type, Options: value.Options, + ReferenceAnswer: value.ReferenceAnswer, Deprecated: value.Deprecated, + } +} + +func ParticipantToWeb(value servicemodel.Participant) webmodel.ParticipantResponse { + result := webmodel.ParticipantResponse{ + ID: value.ID, FirstName: value.FirstName, LastName: value.LastName, + Email: value.Email, ContactInfo: value.ContactInfo, CVFilename: value.CVFilename, + Traits: traitsToWeb(value.Traits), CreatedAt: value.CreatedAt, + } + if value.Birthday != nil { + birthday := value.Birthday.Format("2006-01-02") + result.Birthday = &birthday + } + if len(value.Photo) > 0 { + result.PhotoURL = fmt.Sprintf("/api/participants/%d/photo", value.ID) + } + if len(value.CV) > 0 { + result.CVURL = fmt.Sprintf("/api/participants/%d/cv", value.ID) + } + return result +} + +func CandidateToWeb(value servicemodel.CandidateMatch) webmodel.CandidateResponse { + return webmodel.CandidateResponse{ + Participant: ParticipantToWeb(value.Participant), Score: value.Score, + Matched: value.Matched, Missing: value.Missing, + } +} + +func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel.InvitationResponse { + return webmodel.InvitationResponse{ + ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID, + ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail, + Token: value.Token, Status: value.Status, + ParticipantURL: frontendURL + "/interview/" + value.Token, + AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, + } +} + +func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InterviewResponse { + questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions)) + for i, question := range value.Job.Questions { + questions[i] = webmodel.InterviewQuestionResponse{ + ID: question.ID, Text: question.Text, Type: question.Type, Options: question.Options, + } + } + return webmodel.InterviewResponse{ + Invitation: InvitationToWeb(value.Invitation, frontendURL), + Job: webmodel.InterviewJobResponse{ + ID: value.Job.ID, Title: value.Job.Title, Description: value.Job.Description, + Position: value.Job.Position, Seniority: value.Job.Seniority, + DurationMinutes: value.Job.DurationMinutes, Questions: questions, + }, + Answers: value.Answers, + } +} + +func traitsToService(values []string) []servicemodel.Trait { + result := make([]servicemodel.Trait, len(values)) + for i, value := range values { + result[i] = servicemodel.Trait{Name: value} + } + return result +} + +func traitsToWeb(values []servicemodel.Trait) []string { + result := make([]string, len(values)) + for i, value := range values { + result[i] = value.Name + } + return result +} diff --git a/backend/internal/web/model/models.go b/backend/internal/web/model/models.go new file mode 100644 index 0000000..6abf6a7 --- /dev/null +++ b/backend/internal/web/model/models.go @@ -0,0 +1,140 @@ +package model + +import "time" + +type ErrorResponse struct { + Error string `json:"error"` +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type LoginResponse struct { + Token string `json:"token"` + User User `json:"user"` +} + +type User struct { + ID uint `json:"id"` + Email string `json:"email"` + Role string `json:"role"` +} + +type JobRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Seniority string `json:"seniority"` + Position string `json:"position"` + DurationMinutes int `json:"durationMinutes"` + Labels []string `json:"labels"` + RequiredSkills []string `json:"requiredSkills"` + AdditionalSkills []string `json:"additionalSkills"` +} + +type JobResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Seniority string `json:"seniority"` + Position string `json:"position"` + Status string `json:"status"` + DurationMinutes int `json:"durationMinutes"` + Labels []string `json:"labels"` + RequiredSkills []string `json:"requiredSkills"` + AdditionalSkills []string `json:"additionalSkills"` + Questions []QuestionResponse `json:"questions"` + CreatedAt time.Time `json:"createdAt"` +} + +type QuestionRequest struct { + Text string `json:"text"` + Type string `json:"type"` + Options []string `json:"options"` + ReferenceAnswer string `json:"referenceAnswer"` +} + +type QuestionResponse struct { + ID uint `json:"id"` + Text string `json:"text"` + Type string `json:"type"` + Options []string `json:"options"` + ReferenceAnswer string `json:"referenceAnswer"` + Deprecated bool `json:"deprecated"` +} + +type QuestionStatusRequest struct { + Deprecated bool `json:"deprecated"` +} + +type JobStatusRequest struct { + Status string `json:"status"` + DurationMinutes int `json:"durationMinutes"` +} + +type InvitationRequest struct { + ParticipantID uint `json:"participantId"` +} + +type ParticipantResponse struct { + ID uint `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Birthday *string `json:"birthday,omitempty"` + Email string `json:"email"` + ContactInfo string `json:"contactInfo"` + PhotoURL string `json:"photoUrl,omitempty"` + CVFilename string `json:"cvFilename,omitempty"` + CVURL string `json:"cvUrl,omitempty"` + Traits []string `json:"traits"` + CreatedAt time.Time `json:"createdAt"` +} + +type CandidateResponse struct { + Participant ParticipantResponse `json:"participant"` + Score float64 `json:"score"` + Matched []string `json:"matchedTraits"` + Missing []string `json:"missingTraits"` +} + +type InvitationResponse struct { + ID uint `json:"id"` + JobID uint `json:"jobId"` + ParticipantID uint `json:"participantId"` + ParticipantName string `json:"participantName"` + ParticipantEmail string `json:"participantEmail"` + Token string `json:"token"` + Status string `json:"status"` + ParticipantURL string `json:"participantUrl"` + AcceptedAt *time.Time `json:"acceptedAt,omitempty"` + CompletedAt *time.Time `json:"completedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type InterviewJobResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Position string `json:"position"` + Seniority string `json:"seniority"` + DurationMinutes int `json:"durationMinutes"` + Questions []InterviewQuestionResponse `json:"questions"` +} + +type InterviewQuestionResponse struct { + ID uint `json:"id"` + Text string `json:"text"` + Type string `json:"type"` + Options []string `json:"options"` +} + +type InterviewResponse struct { + Invitation InvitationResponse `json:"invitation"` + Job InterviewJobResponse `json:"job"` + Answers map[uint]string `json:"answers"` +} + +type AnswerRequest struct { + Answer string `json:"answer"` +} diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go new file mode 100644 index 0000000..96e1bc7 --- /dev/null +++ b/backend/internal/web/router/router.go @@ -0,0 +1,93 @@ +package router + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/web/handler" +) + +type contextKey string + +const claimsKey contextKey = "claims" + +func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler { + router := chi.NewRouter() + router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) + router.Use(cors(frontendURL)) + router.Get("/api/health", h.Health) + router.Post("/api/auth/login", h.Login) + router.Get("/api/interviews/{token}", h.GetInterview) + router.Post("/api/interviews/{token}/accept", h.AcceptInterview) + router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer) + router.Post("/api/interviews/{token}/finish", h.FinishInterview) + router.Group(func(protected chi.Router) { + protected.Use(authenticate(tokens)) + protected.Get("/api/jobs", h.ListJobs) + protected.Post("/api/jobs", h.CreateJob) + protected.Get("/api/jobs/{id}", h.GetJob) + protected.Patch("/api/jobs/{id}", h.UpdateJob) + protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion) + protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion) + protected.Get("/api/jobs/{id}/candidates", h.Candidates) + protected.Get("/api/jobs/{id}/invitations", h.ListInvitations) + protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) + protected.Get("/api/questions", h.ListQuestions) + protected.Post("/api/questions", h.CreateQuestion) + protected.Put("/api/questions/{id}", h.UpdateQuestion) + protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated) + protected.Get("/api/participants", h.ListParticipants) + protected.Post("/api/participants", h.CreateParticipant) + protected.Get("/api/participants/{id}", h.GetParticipant) + protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto) + protected.Get("/api/participants/{id}/cv", h.ParticipantCV) + }) + return router +} + +func writeUnauthorized(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) +} + +func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + writeUnauthorized(w, "missing bearer token") + return + } + claims, err := tokens.Parse(raw) + if err != nil { + writeUnauthorized(w, "invalid bearer token") + return + } + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) + }) + } +} + +func cors(origin string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Add("Vary", "Origin") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go new file mode 100644 index 0000000..1a158e0 --- /dev/null +++ b/backend/internal/web/router/router_test.go @@ -0,0 +1,112 @@ +package router + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service" + "github.com/danyel/go-guess/backend/internal/service/model" + "github.com/danyel/go-guess/backend/internal/web/handler" +) + +type authStub struct{} + +func (authStub) Login(context.Context, string, string) (string, model.User, error) { + return "", model.User{}, service.ErrInvalidCredentials +} + +type jobStub struct{} + +func (jobStub) List(context.Context) ([]model.JobPosting, error) { return []model.JobPosting{}, nil } +func (jobStub) Get(context.Context, uint) (model.JobPosting, error) { return model.JobPosting{}, nil } +func (jobStub) Create(context.Context, model.JobPosting) (model.JobPosting, error) { + return model.JobPosting{}, nil +} +func (jobStub) Update(context.Context, uint, string, int) (model.JobPosting, error) { + return model.JobPosting{}, nil +} +func (jobStub) AttachQuestion(context.Context, uint, uint) error { return nil } +func (jobStub) DetachQuestion(context.Context, uint, uint) error { return nil } +func (jobStub) Candidates(context.Context, uint) ([]model.CandidateMatch, error) { + return []model.CandidateMatch{}, nil +} + +type participantStub struct{} + +func (participantStub) List(context.Context) ([]model.Participant, error) { return nil, nil } +func (participantStub) Get(context.Context, uint) (model.Participant, error) { + return model.Participant{}, nil +} +func (participantStub) Create(context.Context, model.Participant) (model.Participant, error) { + return model.Participant{}, nil +} + +type questionStub struct{} + +func (questionStub) List(context.Context, string) ([]model.Question, error) { return nil, nil } +func (questionStub) Create(context.Context, model.Question) (model.Question, error) { + return model.Question{}, nil +} +func (questionStub) Update(context.Context, uint, model.Question) (model.Question, error) { + return model.Question{}, nil +} +func (questionStub) SetDeprecated(context.Context, uint, bool) (model.Question, error) { + return model.Question{}, nil +} + +type invitationStub struct{} + +func (invitationStub) List(context.Context, uint) ([]model.Invitation, error) { return nil, nil } +func (invitationStub) Create(context.Context, uint, uint) (model.Invitation, error) { + return model.Invitation{}, nil +} +func (invitationStub) GetInterview(context.Context, string) (model.Interview, error) { + return model.Interview{}, nil +} +func (invitationStub) Accept(context.Context, string) (model.Interview, error) { + return model.Interview{}, nil +} +func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { return nil } +func (invitationStub) Finish(context.Context, string) (model.Interview, error) { + return model.Interview{}, nil +} + +func TestHealthDoesNotRequireAuthentication(t *testing.T) { + h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173") + tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) + router := New(h, tokens, "http://localhost:5173") + request := httptest.NewRequest(http.MethodGet, "/api/health", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", response.Code) + } +} + +func TestProtectedRouteRequiresValidToken(t *testing.T) { + h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173") + tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) + router := New(h, tokens, "http://localhost:5173") + + unauthorized := httptest.NewRecorder() + router.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/jobs", nil)) + if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("Content-Type") != "application/json" { + t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type")) + } + + token, err := tokens.Issue(1, "interviewer@example.com", "interviewer") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/jobs", nil) + request.Header.Set("Authorization", "Bearer "+token) + authorized := httptest.NewRecorder() + router.ServeHTTP(authorized, request) + if authorized.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", authorized.Code) + } +} diff --git a/backend/migrations/00001_initial.sql b/backend/migrations/00001_initial.sql new file mode 100644 index 0000000..568b406 --- /dev/null +++ b/backend/migrations/00001_initial.sql @@ -0,0 +1,136 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(320) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE traits ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + normalized VARCHAR(100) NOT NULL UNIQUE +); + +CREATE TABLE job_postings ( + id BIGSERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + seniority VARCHAR(50) NOT NULL, + position VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE questions ( + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + text TEXT NOT NULL, + type VARCHAR(32) NOT NULL CHECK (type IN ('open', 'multiple_choice', 'radio', 'code_review')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE question_options ( + id BIGSERIAL PRIMARY KEY, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, + text TEXT NOT NULL +); + +CREATE TABLE participants ( + id BIGSERIAL PRIMARY KEY, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + birthday DATE, + email VARCHAR(320) NOT NULL UNIQUE, + contact_info TEXT, + photo BYTEA, + photo_type VARCHAR(100), + cv BYTEA, + cv_filename VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE job_posting_labels ( + job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE, + PRIMARY KEY (job_posting_id, trait_id) +); + +CREATE TABLE job_required_skills ( + job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE, + PRIMARY KEY (job_posting_id, trait_id) +); + +CREATE TABLE job_additional_skills ( + job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE, + PRIMARY KEY (job_posting_id, trait_id) +); + +CREATE TABLE participant_traits ( + participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + trait_id BIGINT NOT NULL REFERENCES traits(id) ON DELETE CASCADE, + PRIMARY KEY (participant_id, trait_id) +); + +INSERT INTO users (email, password_hash, role) +VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer'); + +INSERT INTO traits (name, normalized) VALUES + ('Go', 'go'), ('PostgreSQL', 'postgresql'), ('Docker', 'docker'), + ('React', 'react'), ('TypeScript', 'typescript'), ('Kubernetes', 'kubernetes'), + ('Backend', 'backend'); + +INSERT INTO job_postings (title, description, seniority, position) VALUES + ('Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', 'Senior', 'Backend Engineer'), + ('Full-stack Product Engineer', 'Deliver accessible operational tools from database to browser.', 'Medior', 'Full-stack Engineer'); + +INSERT INTO job_posting_labels (job_posting_id, trait_id) +SELECT 1, id FROM traits WHERE normalized IN ('backend', 'go') +ON CONFLICT DO NOTHING; +INSERT INTO job_required_skills (job_posting_id, trait_id) +SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker'); +INSERT INTO job_additional_skills (job_posting_id, trait_id) +SELECT 1, id FROM traits WHERE normalized = 'kubernetes'; + +INSERT INTO job_posting_labels (job_posting_id, trait_id) +SELECT 2, id FROM traits WHERE normalized IN ('react', 'typescript'); +INSERT INTO job_required_skills (job_posting_id, trait_id) +SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript'); +INSERT INTO job_additional_skills (job_posting_id, trait_id) +SELECT 2, id FROM traits WHERE normalized = 'docker'; + +INSERT INTO questions (job_id, text, type) VALUES + (1, 'How do you design graceful shutdown for an HTTP service?', 'open'), + (1, 'Which construct protects shared data across goroutines?', 'radio'), + (2, 'Review this component and identify unnecessary renders.', 'code_review'); +INSERT INTO question_options (question_id, text) VALUES + (2, 'sync.Mutex'), (2, 'context.Context'), (2, 'http.Client'); + +INSERT INTO participants (first_name, last_name, birthday, email, contact_info, cv, cv_filename) VALUES + ('Ada', 'Lovelace', '1990-12-10', 'ada@example.com', '+32 470 00 00 01', encode('Senior backend engineer with Go, PostgreSQL and Docker experience.', 'escape')::bytea, 'ada-cv.txt'), + ('Grace', 'Hopper', '1988-12-09', 'grace@example.com', '+32 470 00 00 02', encode('Full-stack developer working with Go, React and TypeScript.', 'escape')::bytea, 'grace-cv.txt'); + +INSERT INTO participant_traits (participant_id, trait_id) +SELECT 1, id FROM traits WHERE normalized IN ('go', 'postgresql', 'docker'); +INSERT INTO participant_traits (participant_id, trait_id) +SELECT 2, id FROM traits WHERE normalized IN ('go', 'react', 'typescript'); + +-- +goose Down +DROP TABLE IF EXISTS participant_traits; +DROP TABLE IF EXISTS job_additional_skills; +DROP TABLE IF EXISTS job_required_skills; +DROP TABLE IF EXISTS job_posting_labels; +DROP TABLE IF EXISTS participants; +DROP TABLE IF EXISTS question_options; +DROP TABLE IF EXISTS questions; +DROP TABLE IF EXISTS job_postings; +DROP TABLE IF EXISTS traits; +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/00002_question_library_and_invitations.sql b/backend/migrations/00002_question_library_and_invitations.sql new file mode 100644 index 0000000..d5673d8 --- /dev/null +++ b/backend/migrations/00002_question_library_and_invitations.sql @@ -0,0 +1,61 @@ +-- +goose Up +ALTER TABLE job_postings + ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'published', 'deprecated')), + ADD COLUMN duration_minutes INTEGER NOT NULL DEFAULT 60 + CHECK (duration_minutes BETWEEN 1 AND 480); + +UPDATE job_postings SET status = 'published'; + +ALTER TABLE questions ADD COLUMN deprecated BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE job_posting_questions ( + job_posting_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + PRIMARY KEY (job_posting_id, question_id) +); + +INSERT INTO job_posting_questions (job_posting_id, question_id) +SELECT job_id, id FROM questions WHERE job_id IS NOT NULL; + +ALTER TABLE questions DROP COLUMN job_id; + +CREATE TABLE invitations ( + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + status VARCHAR(32) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'completed')), + accepted_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (job_id, participant_id) +); + +CREATE TABLE invitation_answers ( + id BIGSERIAL PRIMARY KEY, + invitation_id BIGINT NOT NULL REFERENCES invitations(id) ON DELETE CASCADE, + question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT, + answer TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (invitation_id, question_id) +); + +-- +goose Down +DROP TABLE IF EXISTS invitation_answers; +DROP TABLE IF EXISTS invitations; +ALTER TABLE questions ADD COLUMN job_id BIGINT REFERENCES job_postings(id) ON DELETE CASCADE; +UPDATE questions q +SET job_id = links.job_posting_id +FROM ( + SELECT question_id, min(job_posting_id) AS job_posting_id + FROM job_posting_questions + GROUP BY question_id +) links +WHERE links.question_id = q.id; +DROP TABLE IF EXISTS job_posting_questions; +ALTER TABLE questions DROP COLUMN deprecated; +ALTER TABLE job_postings DROP COLUMN duration_minutes; +ALTER TABLE job_postings DROP COLUMN status; diff --git a/backend/migrations/00003_invitation_duration.sql b/backend/migrations/00003_invitation_duration.sql new file mode 100644 index 0000000..58706ea --- /dev/null +++ b/backend/migrations/00003_invitation_duration.sql @@ -0,0 +1,12 @@ +-- +goose Up +ALTER TABLE invitations + ADD COLUMN duration_minutes INTEGER NOT NULL DEFAULT 60 + CHECK (duration_minutes BETWEEN 1 AND 480); + +UPDATE invitations i +SET duration_minutes = j.duration_minutes +FROM job_postings j +WHERE j.id = i.job_id; + +-- +goose Down +ALTER TABLE invitations DROP COLUMN duration_minutes; diff --git a/backend/migrations/00004_question_reference_answer.sql b/backend/migrations/00004_question_reference_answer.sql new file mode 100644 index 0000000..c2f7f3a --- /dev/null +++ b/backend/migrations/00004_question_reference_answer.sql @@ -0,0 +1,13 @@ +-- +goose Up +ALTER TABLE questions + ADD COLUMN reference_answer TEXT NOT NULL DEFAULT ''; + +UPDATE questions +SET reference_answer = CASE + WHEN type = 'open' THEN 'Use context cancellation, stop accepting work, drain active requests, and close dependencies within a deadline.' + WHEN type = 'code_review' THEN 'Identify unstable dependencies or state updates that cause avoidable renders and propose memoization only where measured.' + ELSE reference_answer +END; + +-- +goose Down +ALTER TABLE questions DROP COLUMN reference_answer; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aff52f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + db: + image: postgres:18-alpine + environment: + POSTGRES_DB: go_guess + POSTGRES_USER: go_guess + POSTGRES_PASSWORD: go_guess + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U go_guess -d go_guess"] + interval: 3s + timeout: 3s + retries: 20 + + api: + build: ./backend + environment: + DATABASE_URL: postgres://go_guess:go_guess@db:5432/go_guess?sslmode=disable + JWT_SECRET: local-development-secret-change-me-12345 + FRONTEND_URL: http://localhost:5173 + ports: + - "${API_PORT:-8080}:8080" + depends_on: + db: + condition: service_healthy + + web: + build: ./frontend + ports: + - "${WEB_PORT:-5173}:80" + depends_on: + - api + +volumes: + postgres_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..c0ce38b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +.git +*.tsbuildinfo diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..c633fe7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +coverage +*.local +*.tsbuildinfo diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..0e75fe5 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +node_modules +dist +coverage diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 0000000..0965e7e --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..16970b8 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:26-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.29-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e742285 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,71 @@ +# Go Guess frontend + +React and TypeScript recruitment workspace built with Vite. + +## Commands + +```bash +npm install +npm run dev +npm run typecheck +npm run lint +npm run format +npm run format:check +npm test +npm run build +``` + +Run one test: + +```bash +npm test -- src/App.test.tsx -t "shows answer inputs" +``` + +## API integration + +Vite proxies `/api` to `http://localhost:8080`. Login stores the returned JWT in +`sessionStorage`; protected requests send it as `Authorization: Bearer `. API failures are +shown in the UI and are never replaced with demo data. + +Supported routes: + +- `POST /api/auth/login` +- `GET|POST /api/jobs` +- `GET /api/jobs/:id` +- `PATCH /api/jobs/:id` for interview status and duration +- `POST|DELETE /api/jobs/:id/questions/:questionId` +- `GET /api/jobs/:id/candidates` +- `GET|POST /api/jobs/:id/invitations` +- `GET|POST /api/questions` (`GET` accepts `?search=`) +- `PUT /api/questions/:id` to update question text, type, options, and reference answer +- `PATCH /api/questions/:id` to deprecate or restore a question +- `GET|POST /api/participants` +- `GET /api/participants/:id` +- `GET /api/participants/:id/photo` +- `GET /api/participants/:id/cv` +- `GET /api/interviews/:token` +- `POST /api/interviews/:token/accept` +- `PUT /api/interviews/:token/answers/:questionId` +- `POST /api/interviews/:token/finish` + +Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`, +`contactInfo`, `photo`, and `cv`. + +## Interview workflow + +Authenticated interviewers create and edit reusable questions at `/questions`, configure job +status and duration, attach active library questions, and issue participant invitations from a job +detail page. Multiple-choice and radio questions require at least two explicitly added options. +Detaching a question removes only the job association; questions can be deprecated but not deleted. +Open-answer and code-review questions include an interviewer-only reference answer that is never +rendered in the participant interview. + +Participants open the generated `/interview/:token` URL without signing in. The invitation token +authorizes the public interview API. Answers are saved when navigating between questions or +finishing, and the countdown is derived from the backend `acceptedAt` timestamp plus the job +duration so refreshing cannot reset it. + +## Container + +Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and +proxies `/api` to the Docker Compose service `api:8080`. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..888eb39 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,25 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..ad5434b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Go Guess · Recruitment workspace + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..416a992 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ec67861 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3937 @@ +{ + "name": "go-guess-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "go-guess-frontend", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.544.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^17.3.0", + "jsdom": "^30.1.0", + "prettier": "^3.9.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", + "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.2.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.0.tgz", + "integrity": "sha512-qKvLetaSd/OTWif9JT0eu1SHBg3H7Nvp9SoMwMxqT5tmYyrxea/P1A5pIfGbnIJlOtm3yVeqw+ORxDOkjN1aEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", + "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.1.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..476fd8d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "go-guess-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b --pretty false", + "lint": "eslint . --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "lucide-react": "^0.544.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.11.0", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^17.3.0", + "jsdom": "^30.1.0", + "prettier": "^3.9.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" + } +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..0056860 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,395 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BrowserRouter, MemoryRouter } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import App from './App' +import type { Job, Participant } from './types' + +const question = { + id: 11, + text: 'How do you design a resilient Go service?', + type: 'open' as const, + options: [], + referenceAnswer: 'Use timeouts, retries, and idempotency.', + deprecated: false, +} + +const job: Job = { + id: 7, + title: 'Senior Go Engineer', + description: 'Build reliable services.', + seniority: 'Senior', + position: 'Backend Engineer', + labels: ['Platform'], + requiredSkills: ['Go'], + additionalSkills: ['Kubernetes'], + status: 'draft', + durationMinutes: 10, + questions: [], + createdAt: '2026-09-01T10:00:00Z', +} + +const participant: Participant = { + id: 3, + firstName: 'Amélie', + lastName: 'Dubois', + email: 'amelie@example.com', + contactInfo: 'Brussels · +32 470 00 00 00', + traits: ['Go', 'PostgreSQL'], + createdAt: '2026-09-02T10:00:00Z', +} + +function response(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ) +} + +function mockApi() { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path === '/api/auth/login') + return response({ + token: 'jwt-token', + user: { id: 1, email: 'admin@example.com', role: 'admin' }, + }) + if (path === '/api/jobs') return response([job]) + if (path === '/api/participants') return response([participant]) + if (path === '/api/jobs/7') return response(job) + if (path === '/api/jobs/7/candidates') + return response([ + { + participant, + score: 90, + matchedTraits: ['Go'], + missingTraits: [], + }, + ]) + if (path === '/api/jobs/7/invitations' && method === 'GET') return response([]) + if (path === '/api/jobs/7/invitations' && method === 'POST') + return response({ + id: 31, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'invite-token', + status: 'pending', + participantUrl: 'https://example.test/interview/invite-token', + acceptedAt: null, + completedAt: null, + createdAt: '2026-09-19T10:00:00Z', + }) + if (path.startsWith('/api/questions') && method === 'GET') return response([question]) + if (path === '/api/questions' && method === 'POST') + return response({ ...question, id: 12, text: 'Explain channels.' }, 201) + if (path === '/api/questions/11' && method === 'PATCH') + return response({ ...question, deprecated: true }) + if (path === '/api/questions/11' && method === 'PUT') + return response({ + ...question, + text: 'Updated resilience question', + referenceAnswer: 'Prefer bounded retries with jitter.', + }) + if (path === '/api/jobs/7/questions/11') return response({}, method === 'DELETE' ? 200 : 201) + throw new Error(`Unexpected request: ${path}`) + }), + ) +} + +function authenticate() { + sessionStorage.setItem('go-guess-token', 'jwt-token') + sessionStorage.setItem( + 'go-guess-user', + JSON.stringify({ id: 1, email: 'admin@example.com', role: 'admin' }), + ) +} + +function renderApp(path = '/') { + return render( + + + , + ) +} + +describe('Go Guess frontend', () => { + beforeEach(() => { + vi.useRealTimers() + sessionStorage.clear() + mockApi() + }) + + it('persists login, navigates from the login URL, and shows API jobs', async () => { + const user = userEvent.setup() + window.history.replaceState({}, '', '/login') + render( + + + , + ) + await user.type(screen.getByLabelText(/email address/i), 'interviewer@go-guess.local') + await user.type(screen.getByLabelText(/password/i), 'admin123') + await user.click(screen.getByRole('button', { name: /sign in/i })) + + expect(await screen.findByRole('heading', { name: 'Job postings' })).toBeInTheDocument() + expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument() + expect(sessionStorage.getItem('go-guess-token')).toBe('jwt-token') + expect(window.location.pathname).toBe('/jobs') + }) + + it('filters jobs by title', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/jobs') + expect(await screen.findByRole('link', { name: 'Senior Go Engineer' })).toBeInTheDocument() + + await user.type(screen.getByRole('textbox', { name: /search jobs/i }), 'designer') + expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument() + }) + + it('adds multiple choice options with the accessible plus button', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/jobs/7/questions/new') + await screen.findByRole('heading', { name: 'Create a question' }) + await user.type(screen.getByLabelText('Reference answer'), 'This should be cleared.') + await user.selectOptions(screen.getByLabelText('Response type'), 'multiple_choice') + expect(screen.queryByLabelText('Reference answer')).not.toBeInTheDocument() + + const optionInput = screen.getByRole('textbox', { name: 'New option' }) + await user.type(optionInput, 'Retries') + await user.click(screen.getByRole('button', { name: 'Add option' })) + + expect(screen.getByText('Retries')).toBeInTheDocument() + expect(optionInput).toHaveValue('') + + await user.selectOptions(screen.getByLabelText('Response type'), 'open') + expect(screen.getByLabelText('Reference answer')).toHaveValue('') + expect(screen.queryByRole('button', { name: 'Remove option Retries' })).not.toBeInTheDocument() + await user.selectOptions(screen.getByLabelText('Response type'), 'code_review') + expect(screen.getByLabelText('Expected answer')).toBeInTheDocument() + }) + + it('surfaces API failures instead of demo content', async () => { + authenticate() + vi.mocked(fetch).mockImplementation(() => response({ error: 'database unavailable' }, 500)) + renderApp('/jobs') + + expect(await screen.findByRole('alert')).toHaveTextContent('database unavailable') + expect(screen.queryByRole('link', { name: 'Senior Go Engineer' })).not.toBeInTheDocument() + }) + + it('exposes participant multipart fields', async () => { + authenticate() + renderApp('/participants/new') + await waitFor(() => expect(fetch).toHaveBeenCalled()) + + expect(screen.getByRole('heading', { name: 'Add a participant' })).toBeInTheDocument() + expect(screen.getByLabelText(/birthday/i)).toHaveAttribute('type', 'date') + expect(screen.getByLabelText(/contact information/i)).toBeRequired() + expect(screen.getByLabelText(/upload cv/i)).toHaveAttribute('accept', '.pdf,.doc,.docx') + }) + + it('creates and deprecates questions in the standalone library', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/questions') + + expect(await screen.findByText(question.text)).toBeInTheDocument() + expect(screen.getByText(question.referenceAnswer)).toBeInTheDocument() + await user.click(screen.getByRole('checkbox', { name: `Deprecated: ${question.text}` })) + await user.type(screen.getByLabelText('Question prompt'), 'Explain channels.') + await user.type(screen.getByLabelText('Reference answer'), 'Mention ownership and closure.') + await user.click(screen.getByRole('button', { name: /create question/i })) + + expect(fetch).toHaveBeenCalledWith( + '/api/questions', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + text: 'Explain channels.', + type: 'open', + options: [], + referenceAnswer: 'Mention ownership and closure.', + }), + }), + ) + expect(fetch).toHaveBeenCalledWith( + '/api/questions/11', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ deprecated: true }) }), + ) + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument() + }) + + it('populates and persists the reference answer while editing', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/questions') + + await user.click(await screen.findByRole('button', { name: `Edit ${question.text}` })) + const prompt = screen.getByLabelText('Question prompt') + await user.clear(prompt) + await user.type(prompt, 'Updated resilience question') + const reference = screen.getByLabelText('Reference answer') + expect(reference).toHaveValue(question.referenceAnswer) + await user.clear(reference) + await user.type(reference, 'Prefer bounded retries with jitter.') + + await user.click(screen.getByRole('button', { name: 'Save changes' })) + + expect(fetch).toHaveBeenCalledWith( + '/api/questions/11', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + text: 'Updated resilience question', + type: 'open', + options: [], + referenceAnswer: 'Prefer bounded retries with jitter.', + }), + }), + ) + }) + + it('attaches and detaches library questions without deleting them', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/jobs/7') + + await user.click(await screen.findByRole('tab', { name: /questions/i })) + await user.click(await screen.findByRole('button', { name: 'Attach' })) + await user.click(screen.getByRole('button', { name: `Detach ${question.text}` })) + + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/questions/11', + expect.objectContaining({ method: 'POST' }), + ) + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/questions/11', + expect.objectContaining({ method: 'DELETE' }), + ) + expect(fetch).not.toHaveBeenCalledWith( + '/api/questions/11', + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('sends invitations and displays the generated participant URL', async () => { + authenticate() + const user = userEvent.setup() + renderApp('/jobs/7') + + await user.click(await screen.findByRole('tab', { name: /invitations/i })) + await user.selectOptions(screen.getByLabelText('Eligible candidate'), '3') + await user.click(screen.getByRole('button', { name: /send invitation/i })) + + expect( + await screen.findByText('https://example.test/interview/invite-token'), + ).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/invitations', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ participantId: 3 }), + }), + ) + }) + + it('navigates participant questions, persists answers, and derives the timer from acceptedAt', async () => { + vi.useFakeTimers() + const now = new Date('2026-09-19T12:00:00Z') + vi.setSystemTime(now) + const interview = { + invitation: { + id: 31, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'invite-token', + status: 'pending', + participantUrl: 'https://example.test/interview/invite-token', + acceptedAt: null, + completedAt: null, + createdAt: now.toISOString(), + }, + job: { + id: 7, + title: job.title, + description: job.description, + position: job.position, + seniority: job.seniority, + durationMinutes: 10, + questions: [question, { ...question, id: 12, text: 'How do you test it?' }], + }, + answers: {}, + } + const acceptedInterview = { + ...interview, + invitation: { + ...interview.invitation, + status: 'accepted', + acceptedAt: now.toISOString(), + }, + } + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + if (path === '/api/interviews/invite-token' && !options?.method) return response(interview) + if (path.endsWith('/accept')) return response(acceptedInterview) + if (path.includes('/answers/')) return response({}) + if (path.endsWith('/finish')) + return response({ + ...acceptedInterview, + invitation: { + ...acceptedInterview.invitation, + status: 'completed', + completedAt: now.toISOString(), + }, + }) + throw new Error(`Unexpected request: ${path}`) + }) + renderApp('/interview/invite-token') + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(screen.queryByRole('textbox', { name: 'Your answer' })).not.toBeInTheDocument() + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Accept and start' })) + }) + expect(screen.getByText(question.text)).toBeInTheDocument() + expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument() + expect(screen.getByTestId('countdown')).toHaveTextContent('10:00') + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + expect(screen.getByTestId('countdown')).toHaveTextContent('09:59') + + fireEvent.change(screen.getByLabelText('Your answer'), { + target: { value: 'Use retries and timeouts.' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Next' })) + }) + expect(screen.getByText('How do you test it?')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith( + '/api/interviews/invite-token/answers/11', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ answer: 'Use retries and timeouts.' }), + }), + ) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'First' })) + }) + expect(screen.getByText(question.text)).toBeInTheDocument() + vi.useRealTimers() + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1486b9c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,2100 @@ +import { + ArrowLeft, + ArrowRight, + BriefcaseBusiness, + Check, + ChevronRight, + CircleHelp, + Clock3, + Copy, + FileText, + LayoutDashboard, + LogOut, + Menu, + Pencil, + Plus, + Search, + Send, + Sparkles, + Trash2, + Upload, + UserRound, + UsersRound, + X, +} from 'lucide-react' +import { + createContext, + type FormEvent, + type ReactNode, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import { + Link, + NavLink, + Navigate, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from 'react-router-dom' +import { api, ApiError, authStorage } from './api' +import type { + CandidateMatch, + CreateJobInput, + CreateParticipantInput, + CreateQuestionInput, + Interview, + Invitation, + Job, + JobStatus, + Participant, + Question, + QuestionType, +} from './types' + +interface AppData { + jobs: Job[] + participants: Participant[] + loading: boolean + error: string + createJob: (input: CreateJobInput) => Promise + createParticipant: (input: CreateParticipantInput) => Promise + reload: () => Promise +} + +const DataContext = createContext(null) + +function useData() { + const value = useContext(DataContext) + if (!value) throw new Error('useData must be used within DataProvider') + return value +} + +function errorMessage(error: unknown) { + return error instanceof ApiError || error instanceof Error + ? error.message + : 'Something went wrong' +} + +function DataProvider({ children }: { children: ReactNode }) { + const [jobs, setJobs] = useState([]) + const [participants, setParticipants] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function reload() { + setLoading(true) + setError('') + try { + const [jobItems, participantItems] = await Promise.all([ + api.jobs.list(), + api.participants.list(), + ]) + setJobs(jobItems) + setParticipants(participantItems) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setLoading(false) + } + } + + useEffect(() => { + let active = true + void Promise.all([api.jobs.list(), api.participants.list()]) + .then(([jobItems, participantItems]) => { + if (!active) return + setJobs(jobItems) + setParticipants(participantItems) + }) + .catch((requestError: unknown) => { + if (active) setError(errorMessage(requestError)) + }) + .finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + }, []) + + async function createJob(input: CreateJobInput) { + const created = await api.jobs.create(input) + setJobs((current) => [created, ...current]) + return created + } + + async function createParticipant(input: CreateParticipantInput) { + const created = await api.participants.create(input) + setParticipants((current) => [created, ...current]) + return created + } + + return ( + + {children} + + ) +} + +function Logo() { + return ( + + + + GO GUESS + Talent workspace + + + ) +} + +function LoginPage({ onLogin }: { onLogin: () => void }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + async function submit(event: FormEvent) { + event.preventDefault() + setLoading(true) + setError('') + const form = new FormData(event.currentTarget) + try { + const auth = await api.login(String(form.get('email')), String(form.get('password'))) + authStorage.save(auth) + onLogin() + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ Better hiring, clearly connected +

Find the right person for every journey.

+

Bring roles, assessments, and candidate insight together in one workspace.

+
+ + + Skills-first matching + Structured signals for confident decisions + +
+
+

Designed for fair, collaborative recruitment.

+
+
+
+ Welcome back +

Sign in to your workspace

+

Use your work account to continue.

+
+ + + + + + + {error && } + + +

+ Contact your administrator for access. +

+
+
+
+ ) +} + +function AppLayout({ onLogout }: { onLogout: () => void }) { + const [open, setOpen] = useState(false) + const user = authStorage.user() + return ( +
+
+ + +
+ {open && ( + +
+ +
+
+ {user?.email.slice(0, 2).toUpperCase() ?? 'U'} + + {user?.email ?? 'Signed in'} + {user?.role ?? 'User'} + +
+ +
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + ) +} + +function NavItem({ + to, + label, + icon, + close, +}: { + to: string + label: string + icon: ReactNode + close: () => void +}) { + return ( + (isActive ? 'nav-link active' : 'nav-link')} + > + {icon} + {label} + + ) +} + +function PageHeader({ + eyebrow, + title, + description, + action, +}: { + eyebrow: string + title: string + description?: string + action?: ReactNode +}) { + return ( +
+
+ {eyebrow} +

{title}

+ {description &&

{description}

} +
+ {action} +
+ ) +} + +function JobsPage() { + const { jobs, loading, error, reload } = useData() + const [query, setQuery] = useState('') + const filtered = jobs.filter((job) => + `${job.title} ${job.position} ${job.seniority} ${job.labels.join(' ')}` + .toLowerCase() + .includes(query.toLowerCase()), + ) + return ( + <> + + Create job + + } + /> + {error && } +
+ + + sum + job.questions.length, 0))} + /> +
+
+
+ + {filtered.length} jobs +
+ {loading ? ( + + ) : ( +
+ + + + + + + + + + + + + {filtered.map((job) => ( + + + + + + + + + ))} + +
RoleSeniorityPositionStatusQuestions + View +
+ + {job.title} + + {job.labels.join(' · ') || 'No labels'} + + + {job.position} + + {job.questions.length} + + + +
+
+ )} + {!loading && !filtered.length && !error && ( + + )} +
+ + ) +} + +function JobDetailPage() { + const id = Number(useParams().jobId) + const [job, setJob] = useState() + const [matches, setMatches] = useState([]) + const [invitations, setInvitations] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>( + 'overview', + ) + + async function load() { + setLoading(true) + setError('') + try { + const [jobValue, candidates, invitationItems] = await Promise.all([ + api.jobs.get(id), + api.jobs.candidates(id), + api.jobs.invitations(id), + ]) + setJob(jobValue) + setMatches(candidates) + setInvitations(invitationItems) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setLoading(false) + } + } + useEffect(() => { + let active = true + void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)]) + .then(([jobValue, candidates, invitationItems]) => { + if (!active) return + setJob(jobValue) + setMatches(candidates) + setInvitations(invitationItems) + }) + .catch((requestError: unknown) => { + if (active) setError(errorMessage(requestError)) + }) + .finally(() => { + if (active) setLoading(false) + }) + return () => { + active = false + } + }, [id]) + if (loading) return + if (error) return + if (!job) return + + return ( + <> + + All jobs + + +
+ {(['overview', 'questions', 'candidates', 'invitations'] as const).map((item) => ( + + ))} +
+ {tab === 'overview' && ( +
+
+

About the role

+

{job.description}

+

Required skills

+ +

Additional skills

+ +
+ +
+ )} + {tab === 'questions' && } + {tab === 'candidates' && } + {tab === 'invitations' && ( + setInvitations((current) => [invitation, ...current])} + /> + )} + + ) +} + +function JobSettings({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) { + const [status, setStatus] = useState(job.status) + const [durationMinutes, setDurationMinutes] = useState(job.durationMinutes) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function save(event: FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + try { + onUpdated(await api.jobs.update(job.id, { status, durationMinutes })) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setSaving(false) + } + } + + return ( +
+ + + + + setDurationMinutes(Number(event.target.value))} + /> + + {error && } + + + ) +} + +function JobQuestionsView({ job, onUpdated }: { job: Job; onUpdated: (job: Job) => void }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [error, setError] = useState('') + const attached = new Set(job.questions.map((question) => question.id)) + + useEffect(() => { + let active = true + const timeout = window.setTimeout(() => { + void api.questions + .list(query) + .then((questions) => { + if (active) setResults(questions) + }) + .catch((requestError) => { + if (active) setError(errorMessage(requestError)) + }) + }, 200) + return () => { + active = false + window.clearTimeout(timeout) + } + }, [query]) + + async function attach(question: Question) { + setError('') + try { + await api.jobs.attachQuestion(job.id, question.id) + onUpdated({ ...job, questions: [...job.questions, question] }) + } catch (requestError) { + setError(errorMessage(requestError)) + } + } + + async function detach(question: Question) { + setError('') + try { + await api.jobs.detachQuestion(job.id, question.id) + onUpdated({ + ...job, + questions: job.questions.filter((item) => item.id !== question.id), + }) + } catch (requestError) { + setError(errorMessage(requestError)) + } + } + + return ( +
+
+
+
+

Assessment questions

+

Detach removes only this job’s link to the library question.

+
+
+
+ {job.questions.map((question, index) => ( + void detach(question)} + aria-label={`Detach ${question.text}`} + > + Detach + + } + /> + ))} + {!job.questions.length && ( + + )} +
+
+
+
+
+

Question library

+

Search reusable, active questions.

+
+ + Manage library + +
+
+ + {error && } +
+
+ {results + .filter((question) => !question.deprecated && !attached.has(question.id)) + .map((question) => ( + void attach(question)} + > + Attach + + } + /> + ))} +
+
+
+ ) +} + +function InvitationsView({ + job, + matches, + invitations, + onInvited, +}: { + job: Job + matches: CandidateMatch[] + invitations: Invitation[] + onInvited: (invitation: Invitation) => void +}) { + const [participantId, setParticipantId] = useState('') + const [sending, setSending] = useState(false) + const [error, setError] = useState('') + const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) + const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) + + async function send(event: FormEvent) { + event.preventDefault() + if (!participantId) return + setSending(true) + setError('') + try { + onInvited(await api.jobs.invite(job.id, Number(participantId))) + setParticipantId('') + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setSending(false) + } + } + + async function copy(url: string) { + try { + await navigator.clipboard.writeText(url) + } catch { + setError('Could not copy the participant URL') + } + } + + return ( +
+
+
+

Interview invitations

+

Invite an eligible candidate and share their private participant URL.

+
+
+
+ + + + +
+ {error && } +
+ {invitations.map((invitation) => ( +
+
+ {invitation.participantName} + {invitation.participantEmail} +
+ + {invitation.participantUrl} + +
+ ))} + {!invitations.length && ( + + )} +
+
+ ) +} + +function QuestionRow({ + question, + number, + action, +}: { + question: Question + number?: number + action?: ReactNode +}) { + return ( +
+ {number ? {number} : } +
+
+

{question.type.replace('_', ' ')}

+ {question.deprecated && } +
+

{question.text}

+ {question.options.length > 0 && {question.options.join(' · ')}} + {question.referenceAnswer && ( +
+ Reference answer +

{question.referenceAnswer}

+
+ )} +
+ {action &&
{action}
} +
+ ) +} + +function CandidateMatchView({ matches }: { matches: CandidateMatch[] }) { + return ( +
+
+
+

Candidate match

+

Trait alignment calculated by the API.

+
+ + Match insight + +
+
+ {matches.map((match, index) => ( +
+ {index + 1} + +
+ + {match.participant.firstName} {match.participant.lastName} + + {match.participant.email} +
+
+
+ Matched + {match.matchedTraits.length} +
+ + + +
+
+
+ Missing + {match.missingTraits.length} +
+
+
+ {Math.round(match.score)}% + Overall match +
+
+ ))} + {!matches.length && ( + + )} +
+
+ ) +} + +function CreateJobPage() { + const { createJob } = useData() + const navigate = useNavigate() + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + async function submit(event: FormEvent) { + event.preventDefault() + setSaving(true) + setError('') + const form = new FormData(event.currentTarget) + const split = (name: string) => + String(form.get(name)) + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + try { + const job = await createJob({ + title: String(form.get('title')), + description: String(form.get('description')), + seniority: String(form.get('seniority')), + position: String(form.get('position')), + labels: split('labels'), + requiredSkills: split('requiredSkills'), + additionalSkills: split('additionalSkills'), + }) + navigate(`/jobs/${job.id}`) + } catch (requestError) { + setError(errorMessage(requestError)) + setSaving(false) + } + } + return ( + <> + + Cancel + + +
+
+

Role information

+
+ + + + + + + + + + + + +
+
+
+

Role profile

+ +