diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b409c16..a5f992d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,9 +5,9 @@ Run repository-wide commands from the root: ```bash -make dev # run PostgreSQL, API, and frontend with development fixtures -make test # complete suite, including disposable PostgreSQL integration tests -make test-integration # Go tests; starts its own PostgreSQL Testcontainer +make dev # run PostgreSQL, RabbitMQ, API, and frontend with development fixtures +make test # complete suite, including disposable infrastructure integration tests +make test-integration # Go tests; starts PostgreSQL and RabbitMQ Testcontainers make test-env # isolated seeded UI stack at http://localhost:15173 make test-env-down # remove the test stack and its PostgreSQL volume make lint # go vet and ESLint @@ -33,6 +33,10 @@ The required toolchain is Go 1.27.1 and React 19.3 with TypeScript/Vite. Use `internal/database/{repository,mapper,entity}`. Never expose GORM entities from handlers. - `backend/cmd/seed` applies embedded environment-aware fixtures. +- `backend/internal/eventbus` publishes persisted interview notes to the durable + `go-guess.interview-notes` RabbitMQ fanout exchange. Each API instance consumes + through its own exclusive queue and distributes events to authenticated SSE + subscribers. - `frontend/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. @@ -63,6 +67,14 @@ questions are acceptance-gated and answers are saved before final submission; interviewer review uses `GET /api/jobs/{id}/invitations/{invitationId}`. +Only completed assessment invitations can receive a `passed` or `failed` outcome, +and only passed candidates can be scheduled for an interview. Scheduled +interviews include the creator plus selected co-interviewers. Attendees manage +their invitation through `/api/inbox`, see accepted interviews in `/api/calendar`, +and can write notes only after the interview has started. Notes are private to +authenticated attendees. The opaque `/participant/meeting/:candidateToken` view +exposes only candidate-safe schedule and shared-document data. + ## Repository conventions - Prefix every Go interface name with `I`. diff --git a/Makefile b/Makefile index a5db44d..fca7e23 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,8 @@ test-integration: test-env: COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \ - WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 docker compose up -d --build + WEB_PORT=15173 FRONTEND_URL=http://localhost:15173 RABBITMQ_AMQP_PORT=15673 \ + RABBITMQ_MANAGEMENT_PORT=25673 docker compose up -d --build test-env-down: COMPOSE_PROJECT_NAME=go-guess-test docker compose down -v --remove-orphans diff --git a/README.md b/README.md index 1a3ab43..7d01334 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,14 @@ 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. +timed assessments followed by collaborative 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 +- RabbitMQ 4 for live interview-note fanout ## Run locally @@ -40,8 +41,8 @@ outside local development. ## Quality commands ```bash -make test # full suite, including disposable PostgreSQL integration tests -make test-integration # Go integration suite with its own PostgreSQL container +make test # full suite, including disposable infrastructure integration tests +make test-integration # Go integration suite with PostgreSQL and RabbitMQ containers make lint make build ``` @@ -59,9 +60,10 @@ cd frontend && npm test -- src/App.test.tsx -t "filters jobs by title" ``` Docker must be available for `make test`. The integration suite starts PostgreSQL -18 in a disposable Testcontainer, applies every Goose migration, loads deterministic -test fixtures, exercises login and the complete interview workflow, and removes the -container afterward. +18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration, +loads deterministic test fixtures, exercises login and the complete assessment and +interview workflow, verifies live note fanout, and removes both containers +afterward. Start an isolated application stack populated with test data for manual UI testing: @@ -86,6 +88,19 @@ submission without requiring an interviewer login. Interviewers can review every saved answer from the job's invitation list. Vite proxies `/api` to the Go service during local development. +Completed assessments can be marked passed or failed. A passed candidate can be +scheduled for an interview with a date, location or meeting link, shared document, +and selected co-interviewers. Each interviewer has an inbox for invitations and a +calendar for accepted interviews. During a started interview, attendees can add +private notes; the API persists each note and publishes it through a durable +RabbitMQ fanout exchange so connected interviewer sessions receive it over an +authenticated server-sent-event stream. The candidate receives a separate opaque +meeting link that exposes the schedule and shared document, but never private notes +or assessment reference answers. + +Development fixtures include `co-interviewer@go-guess.local` with password +`admin123`. Additional co-interviewers can be created from the Users screen. + Goose migrations in `backend/migrations` are schema-only and run in every environment. Embedded SQL under `backend/internal/database/seed` separates shared reference traits from idempotent development and test fixtures. Docker Compose diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 9d9aba2..94ac3e5 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -13,6 +13,7 @@ import ( "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/eventbus" "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" @@ -38,8 +39,15 @@ func main() { defer sqlDB.Close() store := repository.New(db) + bus, err := eventbus.NewAMQPBus(cfg.RabbitMQURL) + if err != nil { + slog.Error("connect event bus", "error", err) + os.Exit(1) + } + defer bus.Close() tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL) invitations := service.NewInvitationService(store, cfg.FrontendURL) + interviews := service.NewScheduledInterviewService(store, bus) api := router.New( handler.New( service.NewAuthService(store, tokens), @@ -47,6 +55,8 @@ func main() { service.NewQuestionService(store), service.NewParticipantService(store), invitations, + service.NewUserService(store), + interviews, cfg.MaxUploadMB, cfg.FrontendURL, ), @@ -55,7 +65,7 @@ func main() { ) server := &http.Server{ Addr: cfg.Address, Handler: api, ReadHeaderTimeout: 5 * time.Second, - ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, + ReadTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } go func() { diff --git a/backend/go.mod b/backend/go.mod index 2ecd304..6544390 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,6 +8,10 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 + github.com/pressly/goose/v3 v3.26.0 + github.com/rabbitmq/amqp091-go v1.15.0 + github.com/testcontainers/testcontainers-go v0.44.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 golang.org/x/crypto v0.54.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.0 @@ -55,13 +59,10 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/pressly/goose/v3 v3.26.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.44.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/backend/go.sum b/backend/go.sum index f5f8a71..155ee01 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -18,6 +20,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= @@ -27,6 +31,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= @@ -43,14 +49,14 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -61,12 +67,22 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -87,6 +103,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -97,6 +115,12 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/rabbitmq/amqp091-go v1.15.0 h1:LEQL4/yp48/Wigt6A6XOu18RQRo8ZHtB5I/KZJn+gkw= +github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= @@ -104,10 +128,10 @@ github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUN github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= 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= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= @@ -128,16 +152,20 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -146,11 +174,13 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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= @@ -158,3 +188,15 @@ 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= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index c396ed2..b0cd69d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { TokenTTL time.Duration MaxUploadMB int64 FrontendURL string + RabbitMQURL string } func Load() (Config, error) { @@ -32,6 +33,7 @@ func Load() (Config, error) { TokenTTL: ttl, MaxUploadMB: maxUploadMB, FrontendURL: env("FRONTEND_URL", "http://localhost:5173"), + RabbitMQURL: env("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"), } if len(cfg.JWTSecret) < 32 { return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters") diff --git a/backend/internal/database/entity/models.go b/backend/internal/database/entity/models.go index 4e51ad5..c7d5872 100644 --- a/backend/internal/database/entity/models.go +++ b/backend/internal/database/entity/models.go @@ -6,6 +6,7 @@ type User struct { ID uint `gorm:"primaryKey"` Email string `gorm:"uniqueIndex;size:320;not null"` PasswordHash string `gorm:"not null"` + DisplayName string `gorm:"size:200;not null"` Role string `gorm:"size:32;not null"` CreatedAt time.Time UpdatedAt time.Time @@ -74,6 +75,7 @@ type Invitation struct { ParticipantID uint `gorm:"index;not null"` Token string `gorm:"uniqueIndex;size:64;not null"` Status string `gorm:"size:32;not null;default:pending"` + Outcome string `gorm:"size:32;not null;default:pending"` DurationMinutes int `gorm:"not null"` AcceptedAt *time.Time CompletedAt *time.Time @@ -84,6 +86,46 @@ type Invitation struct { UpdatedAt time.Time } +type ScheduledInterview struct { + ID uint `gorm:"primaryKey"` + JobID uint `gorm:"index;not null"` + InvitationID uint `gorm:"uniqueIndex;not null"` + ParticipantID uint `gorm:"index;not null"` + CreatorID uint `gorm:"index;not null"` + StartsAt time.Time + Location string `gorm:"size:500;not null"` + CandidateToken string `gorm:"uniqueIndex;size:64;not null"` + SharedDocument string `gorm:"type:text;not null;default:''"` + Status string `gorm:"size:32;not null;default:scheduled"` + Job JobPosting + Invitation Invitation + Participant Participant + Creator User + Attendees []InterviewAttendee `gorm:"foreignKey:InterviewID;constraint:OnDelete:CASCADE"` + Notes []InterviewNote `gorm:"foreignKey:InterviewID;constraint:OnDelete:CASCADE"` + CreatedAt time.Time + UpdatedAt time.Time +} + +type InterviewAttendee struct { + ID uint `gorm:"primaryKey"` + InterviewID uint `gorm:"uniqueIndex:interview_user;not null"` + UserID uint `gorm:"uniqueIndex:interview_user;index;not null"` + Status string `gorm:"size:32;not null;default:invited"` + User User + CreatedAt time.Time + UpdatedAt time.Time +} + +type InterviewNote struct { + ID uint `gorm:"primaryKey"` + InterviewID uint `gorm:"index;not null"` + AuthorID uint `gorm:"index;not null"` + Body string `gorm:"type:text;not null"` + Author User + CreatedAt time.Time +} + type InvitationAnswer struct { ID uint `gorm:"primaryKey"` InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"` diff --git a/backend/internal/database/mapper/mapper.go b/backend/internal/database/mapper/mapper.go index f63f744..36173ea 100644 --- a/backend/internal/database/mapper/mapper.go +++ b/backend/internal/database/mapper/mapper.go @@ -6,7 +6,10 @@ import ( ) func UserToService(value dbmodel.User) servicemodel.User { - return servicemodel.User{ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, Role: value.Role} + return servicemodel.User{ + ID: value.ID, Email: value.Email, PasswordHash: value.PasswordHash, + DisplayName: value.DisplayName, Role: value.Role, + } } func JobToService(value dbmodel.JobPosting) servicemodel.JobPosting { @@ -63,7 +66,45 @@ func InvitationToService(value dbmodel.Invitation) 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, + Outcome: value.Outcome, DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, } } + +func ScheduledInterviewToService(value dbmodel.ScheduledInterview) servicemodel.ScheduledInterview { + attendees := make([]servicemodel.InterviewAttendee, len(value.Attendees)) + for i, attendee := range value.Attendees { + attendees[i] = servicemodel.InterviewAttendee{ + ID: attendee.ID, InterviewID: attendee.InterviewID, UserID: attendee.UserID, + Status: attendee.Status, User: UserToService(attendee.User), + CreatedAt: attendee.CreatedAt, UpdatedAt: attendee.UpdatedAt, + } + attendees[i].User.PasswordHash = "" + } + creator := UserToService(value.Creator) + creator.PasswordHash = "" + notes := make([]servicemodel.InterviewNote, len(value.Notes)) + for i, note := range value.Notes { + notes[i] = InterviewNoteToService(note) + } + return servicemodel.ScheduledInterview{ + ID: value.ID, JobID: value.JobID, InvitationID: value.InvitationID, + ParticipantID: value.ParticipantID, CreatorID: value.CreatorID, + StartsAt: value.StartsAt, Location: value.Location, CandidateToken: value.CandidateToken, + SharedDocument: value.SharedDocument, Status: value.Status, + Job: JobToService(value.Job), Invitation: InvitationToService(value.Invitation), + Participant: ParticipantToService(value.Participant), Creator: creator, + Attendees: attendees, Notes: notes, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func InterviewNoteToService(value dbmodel.InterviewNote) servicemodel.InterviewNote { + author := UserToService(value.Author) + author.PasswordHash = "" + return servicemodel.InterviewNote{ + ID: value.ID, InterviewID: value.InterviewID, AuthorID: value.AuthorID, + Author: author, Body: value.Body, CreatedAt: value.CreatedAt, + } +} diff --git a/backend/internal/database/repository/store.go b/backend/internal/database/repository/store.go index 3a286fe..bdc994d 100644 --- a/backend/internal/database/repository/store.go +++ b/backend/internal/database/repository/store.go @@ -15,12 +15,15 @@ import ( ) var ( - ErrNotFound = errors.New("record not found") - ErrConflict = errors.New("record conflicts with existing data") + ErrNotFound = errors.New("record not found") + ErrConflict = errors.New("record conflicts with existing data") + ErrInterviewNotStarted = errors.New("interview is not started") ) type IStore interface { FindUserByEmail(context.Context, string) (servicemodel.User, error) + ListUsers(context.Context) ([]servicemodel.User, error) + CreateUser(context.Context, servicemodel.User) (servicemodel.User, error) ListJobs(context.Context) ([]servicemodel.JobPosting, error) GetJob(context.Context, uint) (servicemodel.JobPosting, error) CreateJob(context.Context, servicemodel.JobPosting) (servicemodel.JobPosting, error) @@ -42,6 +45,19 @@ type IStore interface { AcceptInvitation(context.Context, string, time.Time) error SaveAnswer(context.Context, string, uint, string) error CompleteInvitation(context.Context, string, time.Time) error + GetInvitation(context.Context, uint, uint) (servicemodel.Invitation, error) + UpdateInvitationOutcome(context.Context, uint, uint, string) (servicemodel.Invitation, error) + CreateScheduledInterview(context.Context, servicemodel.ScheduledInterview, []uint) (servicemodel.ScheduledInterview, error) + ListScheduledInterviews(context.Context, uint) ([]servicemodel.ScheduledInterview, error) + GetScheduledInterview(context.Context, uint) (servicemodel.ScheduledInterview, error) + GetScheduledInterviewByToken(context.Context, string) (servicemodel.ScheduledInterview, error) + UpdateScheduledInterviewStatus(context.Context, uint, string) (servicemodel.ScheduledInterview, error) + UpdateScheduledInterviewDocument(context.Context, uint, string) (servicemodel.ScheduledInterview, error) + ListInterviewNotes(context.Context, uint) ([]servicemodel.InterviewNote, error) + CreateInterviewNote(context.Context, servicemodel.InterviewNote) (servicemodel.InterviewNote, error) + ListInbox(context.Context, uint) ([]servicemodel.ScheduledInterview, error) + UpdateAttendeeStatus(context.Context, uint, uint, string) (servicemodel.InterviewAttendee, error) + ListCalendar(context.Context, uint) ([]servicemodel.ScheduledInterview, error) } type Store struct { @@ -60,6 +76,32 @@ func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel return dbmapper.UserToService(value), nil } +func (s *Store) ListUsers(ctx context.Context) ([]servicemodel.User, error) { + var values []dbmodel.User + if err := s.db.WithContext(ctx).Order("display_name, email").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.User, len(values)) + for i, value := range values { + result[i] = dbmapper.UserToService(value) + result[i].PasswordHash = "" + } + return result, nil +} + +func (s *Store) CreateUser(ctx context.Context, value servicemodel.User) (servicemodel.User, error) { + entity := dbmodel.User{ + Email: value.Email, PasswordHash: value.PasswordHash, + DisplayName: value.DisplayName, Role: value.Role, + } + if err := s.db.WithContext(ctx).Create(&entity).Error; err != nil { + return servicemodel.User{}, mapError(err) + } + result := dbmapper.UserToService(entity) + result.PasswordHash = "" + return result, 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 { @@ -370,6 +412,201 @@ func (s *Store) CompleteInvitation(ctx context.Context, token string, completedA return nil } +func (s *Store) GetInvitation(ctx context.Context, jobID, invitationID uint) (servicemodel.Invitation, error) { + var value dbmodel.Invitation + if err := s.db.WithContext(ctx).Preload("Participant"). + Where("id = ? AND job_id = ?", invitationID, jobID).First(&value).Error; err != nil { + return servicemodel.Invitation{}, mapError(err) + } + return dbmapper.InvitationToService(value), nil +} + +func (s *Store) UpdateInvitationOutcome( + ctx context.Context, jobID, invitationID uint, outcome string, +) (servicemodel.Invitation, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.Invitation{}). + Where("id = ? AND job_id = ?", invitationID, jobID).Update("outcome", outcome) + if result.Error != nil { + return servicemodel.Invitation{}, mapError(result.Error) + } + if result.RowsAffected == 0 { + return servicemodel.Invitation{}, ErrNotFound + } + return s.GetInvitation(ctx, jobID, invitationID) +} + +func (s *Store) CreateScheduledInterview( + ctx context.Context, value servicemodel.ScheduledInterview, interviewerIDs []uint, +) (servicemodel.ScheduledInterview, error) { + entity := dbmodel.ScheduledInterview{ + JobID: value.JobID, InvitationID: value.InvitationID, ParticipantID: value.ParticipantID, + CreatorID: value.CreatorID, StartsAt: value.StartsAt, Location: value.Location, + CandidateToken: value.CandidateToken, SharedDocument: value.SharedDocument, Status: value.Status, + } + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&entity).Error; err != nil { + return err + } + attendees := make([]dbmodel.InterviewAttendee, 0, len(interviewerIDs)) + for _, userID := range interviewerIDs { + status := "invited" + if userID == value.CreatorID { + status = "accepted" + } + attendees = append(attendees, dbmodel.InterviewAttendee{ + InterviewID: entity.ID, UserID: userID, Status: status, + }) + } + return tx.Create(&attendees).Error + }) + if err != nil { + return servicemodel.ScheduledInterview{}, mapError(err) + } + return s.GetScheduledInterview(ctx, entity.ID) +} + +func (s *Store) ListScheduledInterviews(ctx context.Context, jobID uint) ([]servicemodel.ScheduledInterview, error) { + var values []dbmodel.ScheduledInterview + if err := preloadScheduledInterview(s.db.WithContext(ctx)).Where("job_id = ?", jobID). + Order("starts_at").Find(&values).Error; err != nil { + return nil, err + } + return scheduledInterviewsToService(values), nil +} + +func (s *Store) GetScheduledInterview(ctx context.Context, id uint) (servicemodel.ScheduledInterview, error) { + var value dbmodel.ScheduledInterview + if err := preloadScheduledInterview(s.db.WithContext(ctx)).First(&value, id).Error; err != nil { + return servicemodel.ScheduledInterview{}, mapError(err) + } + return dbmapper.ScheduledInterviewToService(value), nil +} + +func (s *Store) GetScheduledInterviewByToken(ctx context.Context, token string) (servicemodel.ScheduledInterview, error) { + var value dbmodel.ScheduledInterview + if err := preloadScheduledInterview(s.db.WithContext(ctx)). + Where("candidate_token = ?", token).First(&value).Error; err != nil { + return servicemodel.ScheduledInterview{}, mapError(err) + } + return dbmapper.ScheduledInterviewToService(value), nil +} + +func (s *Store) UpdateScheduledInterviewStatus( + ctx context.Context, id uint, status string, +) (servicemodel.ScheduledInterview, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}). + Where("id = ?", id).Update("status", status) + if result.Error != nil { + return servicemodel.ScheduledInterview{}, result.Error + } + if result.RowsAffected == 0 { + return servicemodel.ScheduledInterview{}, ErrNotFound + } + return s.GetScheduledInterview(ctx, id) +} + +func (s *Store) UpdateScheduledInterviewDocument( + ctx context.Context, id uint, document string, +) (servicemodel.ScheduledInterview, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.ScheduledInterview{}). + Where("id = ?", id).Update("shared_document", document) + if result.Error != nil { + return servicemodel.ScheduledInterview{}, result.Error + } + if result.RowsAffected == 0 { + return servicemodel.ScheduledInterview{}, ErrNotFound + } + return s.GetScheduledInterview(ctx, id) +} + +func (s *Store) ListInterviewNotes(ctx context.Context, interviewID uint) ([]servicemodel.InterviewNote, error) { + var values []dbmodel.InterviewNote + if err := s.db.WithContext(ctx).Preload("Author").Where("interview_id = ?", interviewID). + Order("created_at, id").Find(&values).Error; err != nil { + return nil, err + } + result := make([]servicemodel.InterviewNote, len(values)) + for i, value := range values { + result[i] = dbmapper.InterviewNoteToService(value) + } + return result, nil +} + +func (s *Store) CreateInterviewNote( + ctx context.Context, value servicemodel.InterviewNote, +) (servicemodel.InterviewNote, error) { + entity := dbmodel.InterviewNote{ + InterviewID: value.InterviewID, AuthorID: value.AuthorID, Body: value.Body, + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var interview dbmodel.ScheduledInterview + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Select("id", "status").First(&interview, value.InterviewID).Error; err != nil { + return err + } + if interview.Status != "started" { + return ErrInterviewNotStarted + } + return tx.Create(&entity).Error + }); err != nil { + if errors.Is(err, ErrInterviewNotStarted) { + return servicemodel.InterviewNote{}, err + } + return servicemodel.InterviewNote{}, mapError(err) + } + if err := s.db.WithContext(ctx).Preload("Author").First(&entity, entity.ID).Error; err != nil { + return servicemodel.InterviewNote{}, mapError(err) + } + return dbmapper.InterviewNoteToService(entity), nil +} + +func (s *Store) ListInbox(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) { + var values []dbmodel.ScheduledInterview + if err := preloadScheduledInterview(s.db.WithContext(ctx)). + Joins("JOIN interview_attendees inbox_attendee ON inbox_attendee.interview_id = scheduled_interviews.id"). + Where("inbox_attendee.user_id = ?", userID). + Order("inbox_attendee.created_at DESC").Find(&values).Error; err != nil { + return nil, err + } + return scheduledInterviewsToService(values), nil +} + +func (s *Store) UpdateAttendeeStatus( + ctx context.Context, interviewID, userID uint, status string, +) (servicemodel.InterviewAttendee, error) { + result := s.db.WithContext(ctx).Model(&dbmodel.InterviewAttendee{}). + Where("interview_id = ? AND user_id = ?", interviewID, userID).Update("status", status) + if result.Error != nil { + return servicemodel.InterviewAttendee{}, result.Error + } + if result.RowsAffected == 0 { + return servicemodel.InterviewAttendee{}, ErrNotFound + } + var value dbmodel.InterviewAttendee + if err := s.db.WithContext(ctx).Preload("User"). + Where("interview_id = ? AND user_id = ?", interviewID, userID).First(&value).Error; err != nil { + return servicemodel.InterviewAttendee{}, mapError(err) + } + attendee := servicemodel.InterviewAttendee{ + ID: value.ID, InterviewID: value.InterviewID, UserID: value.UserID, + Status: value.Status, User: dbmapper.UserToService(value.User), + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } + attendee.User.PasswordHash = "" + return attendee, nil +} + +func (s *Store) ListCalendar(ctx context.Context, userID uint) ([]servicemodel.ScheduledInterview, error) { + var values []dbmodel.ScheduledInterview + if err := preloadScheduledInterview(s.db.WithContext(ctx)). + Joins("JOIN interview_attendees ON interview_attendees.interview_id = scheduled_interviews.id"). + Where("interview_attendees.user_id = ? AND interview_attendees.status <> ?", userID, "declined"). + Order("scheduled_interviews.starts_at").Find(&values).Error; err != nil { + return nil, err + } + return scheduledInterviewsToService(values), 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)) @@ -398,6 +635,23 @@ func preloadJob(db *gorm.DB) *gorm.DB { Preload("Questions.Options") } +func preloadScheduledInterview(db *gorm.DB) *gorm.DB { + return db.Preload("Job", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "title") + }).Preload("Participant", func(db *gorm.DB) *gorm.DB { + return db.Select("id", "first_name", "last_name") + }). + Preload("Creator").Preload("Attendees.User").Preload("Notes.Author") +} + +func scheduledInterviewsToService(values []dbmodel.ScheduledInterview) []servicemodel.ScheduledInterview { + result := make([]servicemodel.ScheduledInterview, len(values)) + for i, value := range values { + result[i] = dbmapper.ScheduledInterviewToService(value) + } + return result +} + func mapError(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound diff --git a/backend/internal/database/seed/development.sql b/backend/internal/database/seed/development.sql index a1a983e..3c65c64 100644 --- a/backend/internal/database/seed/development.sql +++ b/backend/internal/database/seed/development.sql @@ -1,6 +1,16 @@ -INSERT INTO users (email, password_hash, role) -VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer') -ON CONFLICT (email) DO NOTHING; +INSERT INTO users (email, password_hash, display_name, role) +VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'Lead Interviewer', 'interviewer') +ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + display_name = EXCLUDED.display_name, + role = EXCLUDED.role; + +INSERT INTO users (email, password_hash, display_name, role) +VALUES ('co-interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'Co-Interviewer', 'interviewer') +ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + display_name = EXCLUDED.display_name, + role = EXCLUDED.role; INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes) SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', diff --git a/backend/internal/database/seed/test.sql b/backend/internal/database/seed/test.sql index a68fe36..ed02a6a 100644 --- a/backend/internal/database/seed/test.sql +++ b/backend/internal/database/seed/test.sql @@ -1,6 +1,9 @@ -INSERT INTO users (email, password_hash, role) -VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'interviewer') -ON CONFLICT (email) DO NOTHING; +INSERT INTO users (email, password_hash, display_name, role) +VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'Integration Interviewer', 'interviewer') +ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + display_name = EXCLUDED.display_name, + role = EXCLUDED.role; INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes) SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.', diff --git a/backend/internal/eventbus/amqp.go b/backend/internal/eventbus/amqp.go new file mode 100644 index 0000000..a3d39d7 --- /dev/null +++ b/backend/internal/eventbus/amqp.go @@ -0,0 +1,212 @@ +package eventbus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + + amqp "github.com/rabbitmq/amqp091-go" +) + +const interviewExchange = "go-guess.interview-notes" + +type AMQPBus struct { + url string + mu sync.Mutex + connection *amqp.Connection + publisher *amqp.Channel + consumer *amqp.Channel + subscribers map[chan InterviewNoteEvent]struct{} + done chan struct{} + closed bool +} + +func NewAMQPBus(url string) (*AMQPBus, error) { + bus := &AMQPBus{ + url: url, subscribers: make(map[chan InterviewNoteEvent]struct{}), done: make(chan struct{}), + } + deliveries, err := bus.connect() + if err != nil { + return nil, err + } + go bus.dispatch(deliveries) + return bus, nil +} + +func (b *AMQPBus) connect() (<-chan amqp.Delivery, error) { + connection, err := amqp.Dial(b.url) + if err != nil { + return nil, fmt.Errorf("connect RabbitMQ: %w", err) + } + publisher, err := connection.Channel() + if err != nil { + _ = connection.Close() + return nil, fmt.Errorf("open RabbitMQ publisher: %w", err) + } + consumer, err := connection.Channel() + if err != nil { + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("open RabbitMQ consumer: %w", err) + } + for _, channel := range []*amqp.Channel{publisher, consumer} { + if err := channel.ExchangeDeclare(interviewExchange, "fanout", true, false, false, false, nil); err != nil { + _ = consumer.Close() + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("declare RabbitMQ exchange: %w", err) + } + } + if err := publisher.Confirm(false); err != nil { + _ = consumer.Close() + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("enable RabbitMQ publisher confirms: %w", err) + } + queue, err := consumer.QueueDeclare("", false, true, true, false, nil) + if err != nil { + _ = consumer.Close() + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("declare RabbitMQ queue: %w", err) + } + if err := consumer.QueueBind(queue.Name, "", interviewExchange, false, nil); err != nil { + _ = consumer.Close() + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("bind RabbitMQ queue: %w", err) + } + deliveries, err := consumer.Consume(queue.Name, "", true, true, false, false, nil) + if err != nil { + _ = consumer.Close() + _ = publisher.Close() + _ = connection.Close() + return nil, fmt.Errorf("consume RabbitMQ events: %w", err) + } + b.connection, b.publisher, b.consumer = connection, publisher, consumer + return deliveries, nil +} + +func (b *AMQPBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error { + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("encode interview note event: %w", err) + } + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return ErrClosed + } + err = b.publishConfirmed(ctx, body) + if err == nil { + return nil + } + if b.consumer != nil { + _ = b.consumer.Close() + } + if b.publisher != nil { + _ = b.publisher.Close() + } + if b.connection != nil { + _ = b.connection.Close() + } + deliveries, reconnectErr := b.connect() + if reconnectErr != nil { + return fmt.Errorf("publish interview note: %w; reconnect: %v", err, reconnectErr) + } + go b.dispatch(deliveries) + if err := b.publishConfirmed(ctx, body); err != nil { + return fmt.Errorf("publish interview note after reconnect: %w", err) + } + return nil +} + +func (b *AMQPBus) publishConfirmed(ctx context.Context, body []byte) error { + confirmation, err := b.publisher.PublishWithDeferredConfirmWithContext( + ctx, interviewExchange, "", false, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, + }) + if err != nil { + return err + } + acknowledged, err := confirmation.WaitContext(ctx) + if err != nil { + return err + } + if !acknowledged { + return errors.New("RabbitMQ rejected interview note") + } + return nil +} + +func (b *AMQPBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return nil, ErrClosed + } + ch := make(chan InterviewNoteEvent, 16) + b.subscribers[ch] = struct{}{} + b.mu.Unlock() + go func() { + <-ctx.Done() + b.mu.Lock() + if _, ok := b.subscribers[ch]; ok { + delete(b.subscribers, ch) + close(ch) + } + b.mu.Unlock() + }() + return ch, nil +} + +func (b *AMQPBus) dispatch(deliveries <-chan amqp.Delivery) { + for { + select { + case <-b.done: + return + case delivery, ok := <-deliveries: + if !ok { + return + } + var event InterviewNoteEvent + if json.Unmarshal(delivery.Body, &event) != nil { + continue + } + b.mu.Lock() + for subscriber := range b.subscribers { + select { + case subscriber <- event: + default: + } + } + b.mu.Unlock() + } + } +} + +func (b *AMQPBus) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil + } + b.closed = true + close(b.done) + for subscriber := range b.subscribers { + close(subscriber) + } + clear(b.subscribers) + if b.consumer != nil { + _ = b.consumer.Close() + } + if b.publisher != nil { + _ = b.publisher.Close() + } + if b.connection != nil { + return b.connection.Close() + } + return nil +} diff --git a/backend/internal/eventbus/eventbus.go b/backend/internal/eventbus/eventbus.go new file mode 100644 index 0000000..9d040fd --- /dev/null +++ b/backend/internal/eventbus/eventbus.go @@ -0,0 +1,86 @@ +package eventbus + +import ( + "context" + "errors" + "sync" + "time" +) + +type InterviewNoteEvent struct { + InterviewID uint `json:"interviewId"` + ID uint `json:"id"` + AuthorUserID uint `json:"authorUserId"` + AuthorName string `json:"authorName"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` +} + +type IEventBus interface { + PublishInterviewNote(context.Context, InterviewNoteEvent) error + SubscribeInterviewNotes(context.Context) (<-chan InterviewNoteEvent, error) + Close() error +} + +var ErrClosed = errors.New("event bus is closed") + +type MemoryBus struct { + mu sync.RWMutex + subscribers map[chan InterviewNoteEvent]struct{} + closed bool +} + +func NewMemoryBus() *MemoryBus { + return &MemoryBus{subscribers: make(map[chan InterviewNoteEvent]struct{})} +} + +func (b *MemoryBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error { + b.mu.RLock() + defer b.mu.RUnlock() + if b.closed { + return ErrClosed + } + for subscriber := range b.subscribers { + select { + case subscriber <- event: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +func (b *MemoryBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) { + b.mu.Lock() + if b.closed { + b.mu.Unlock() + return nil, ErrClosed + } + ch := make(chan InterviewNoteEvent, 16) + b.subscribers[ch] = struct{}{} + b.mu.Unlock() + go func() { + <-ctx.Done() + b.mu.Lock() + if _, ok := b.subscribers[ch]; ok { + delete(b.subscribers, ch) + close(ch) + } + b.mu.Unlock() + }() + return ch, nil +} + +func (b *MemoryBus) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil + } + b.closed = true + for subscriber := range b.subscribers { + close(subscriber) + } + clear(b.subscribers) + return nil +} diff --git a/backend/internal/integration/api_test.go b/backend/internal/integration/api_test.go index 5347a4e..1ecb894 100644 --- a/backend/internal/integration/api_test.go +++ b/backend/internal/integration/api_test.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -26,6 +27,7 @@ import ( "github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/database/seed" + "github.com/danyel/go-guess/backend/internal/eventbus" "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" @@ -33,12 +35,32 @@ import ( "github.com/danyel/go-guess/backend/internal/web/router" ) -var testServer *httptest.Server +var ( + testServer *httptest.Server + testBus eventbus.IEventBus +) func TestMain(m *testing.M) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() + rabbitContainer, err := testcontainers.Run( + ctx, + "rabbitmq:4-management-alpine", + testcontainers.WithExposedPorts("5672/tcp"), + testcontainers.WithEnv(map[string]string{ + "RABBITMQ_DEFAULT_USER": "go_guess", + "RABBITMQ_DEFAULT_PASS": "go_guess", + }), + testcontainers.WithWaitStrategy( + wait.ForListeningPort("5672/tcp").WithStartupTimeout(90*time.Second), + ), + ) + if err != nil { + fmt.Fprintln(os.Stderr, "start RabbitMQ test container:", err) + os.Exit(1) + } + container, err := tcpostgres.Run( ctx, "postgres:18-alpine", @@ -53,6 +75,7 @@ func TestMain(m *testing.M) { ) if err != nil { fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err) + _ = rabbitContainer.Terminate(context.Background()) os.Exit(1) } @@ -95,6 +118,19 @@ func TestMain(m *testing.M) { store := repository.New(gormDB) tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour) invitations := service.NewInvitationService(store, "http://example.test") + rabbitEndpoint, err := rabbitContainer.PortEndpoint(ctx, "5672/tcp", "amqp") + if err != nil { + fmt.Fprintln(os.Stderr, "get RabbitMQ endpoint:", err) + os.Exit(1) + } + rabbitURL := strings.Replace(rabbitEndpoint, "amqp://", "amqp://go_guess:go_guess@", 1) + "/" + amqpBus, err := eventbus.NewAMQPBus(rabbitURL) + if err != nil { + fmt.Fprintln(os.Stderr, "connect RabbitMQ:", err) + os.Exit(1) + } + testBus = amqpBus + interviews := service.NewScheduledInterviewService(store, testBus) testServer = httptest.NewServer(router.New( handler.New( service.NewAuthService(store, tokens), @@ -102,6 +138,8 @@ func TestMain(m *testing.M) { service.NewQuestionService(store), service.NewParticipantService(store), invitations, + service.NewUserService(store), + interviews, 10, "http://example.test", ), @@ -111,9 +149,11 @@ func TestMain(m *testing.M) { code := m.Run() testServer.Close() + _ = amqpBus.Close() _ = sqlDB.Close() cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) _ = container.Terminate(cleanupCtx) + _ = rabbitContainer.Terminate(cleanupCtx) cleanupCancel() os.Exit(code) } @@ -142,12 +182,12 @@ func TestCompleteInterviewWorkflow(t *testing.T) { var question webmodel.QuestionResponse requestJSON(t, http.MethodPost, "/api/questions", login.Token, map[string]any{ "text": "Review this error handling.", "type": "code_review", - "codeSnippet": "func run() error {\n execute()\n return nil\n}", + "codeSnippet": "func run() error {\n execute()\n return nil\n}", "referenceAnswer": "Return the execute error.", "options": []string{}, }, http.StatusCreated, &question) requestJSON(t, http.MethodPut, fmt.Sprintf("/api/questions/%d", question.ID), login.Token, map[string]any{ "text": "Review this ignored error.", "type": "code_review", - "codeSnippet": "func run() error {\n execute()\n return nil\n}", + "codeSnippet": "func run() error {\n execute()\n return nil\n}", "referenceAnswer": "Propagate the execute error.", "options": []string{}, }, http.StatusOK, &question) @@ -221,6 +261,109 @@ func TestCompleteInterviewWorkflow(t *testing.T) { if !found { t.Fatalf("review did not include interviewer question context: %#v", review.Job.Questions) } + + requestJSON(t, http.MethodPatch, + fmt.Sprintf("/api/jobs/%d/invitations/%d/outcome", jobID, invitation.ID), + login.Token, map[string]any{"outcome": "passed"}, http.StatusOK, &invitation) + if invitation.Outcome != "passed" { + t.Fatalf("assessment outcome was not updated: %#v", invitation) + } + + var coInterviewer webmodel.User + requestJSON(t, http.MethodPost, "/api/users", login.Token, map[string]any{ + "email": "co.integration@example.com", "password": "integration-password", + "displayName": "Co Interviewer", + }, http.StatusCreated, &coInterviewer) + if coInterviewer.Role != "co_interviewer" { + t.Fatalf("unexpected co-interviewer: %#v", coInterviewer) + } + + var scheduled webmodel.ScheduledInterviewResponse + requestJSON(t, http.MethodPost, fmt.Sprintf("/api/jobs/%d/interviews", jobID), + login.Token, map[string]any{ + "invitationId": invitation.ID, "startsAt": time.Now().Add(time.Hour), + "location": "Meeting room", "interviewerIds": []uint{coInterviewer.ID}, + "sharedDocument": "Collaborative notes", + }, http.StatusCreated, &scheduled) + if scheduled.CandidateURL != "http://example.test/participant/meeting/"+scheduled.CandidateToken || + len(scheduled.Attendees) != 2 || scheduled.JobTitle == "" || scheduled.ParticipantName == "" { + t.Fatalf("unexpected scheduled interview: %#v", scheduled) + } + + var coLogin webmodel.LoginResponse + requestJSON(t, http.MethodPost, "/api/auth/login", "", map[string]any{ + "email": "co.integration@example.com", "password": "integration-password", + }, http.StatusOK, &coLogin) + + var inbox []webmodel.ScheduledInterviewResponse + requestJSON(t, http.MethodGet, "/api/inbox", coLogin.Token, nil, http.StatusOK, &inbox) + if len(inbox) != 1 || inbox[0].ID != scheduled.ID { + t.Fatalf("scheduled interview missing from co-interviewer inbox: %#v", inbox) + } + requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/inbox/%d", scheduled.ID), + coLogin.Token, map[string]any{"status": "accepted"}, http.StatusOK, &scheduled) + var calendar []webmodel.ScheduledInterviewResponse + requestJSON(t, http.MethodGet, "/api/calendar", coLogin.Token, nil, http.StatusOK, &calendar) + if len(calendar) != 1 || calendar[0].ID != scheduled.ID { + t.Fatalf("accepted interview missing from calendar: %#v", calendar) + } + + requestJSON(t, http.MethodPost, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID), + login.Token, map[string]any{"body": "Too early"}, http.StatusConflict, nil) + requestJSON(t, http.MethodPatch, fmt.Sprintf("/api/scheduled-interviews/%d/status", scheduled.ID), + login.Token, map[string]any{"status": "started"}, http.StatusOK, &scheduled) + + subscriptionContext, cancelSubscriptions := context.WithCancel(context.Background()) + defer cancelSubscriptions() + firstEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext) + if err != nil { + t.Fatal(err) + } + secondEvents, err := testBus.SubscribeInterviewNotes(subscriptionContext) + if err != nil { + t.Fatal(err) + } + + var note webmodel.InterviewNoteResponse + requestJSON(t, http.MethodPost, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID), + coLogin.Token, map[string]any{"body": "Candidate explained the trade-offs clearly."}, + http.StatusCreated, ¬e) + if note.AuthorUserID != coInterviewer.ID || note.AuthorName != "Co Interviewer" { + t.Fatalf("unexpected note response: %#v", note) + } + for index, events := range []<-chan eventbus.InterviewNoteEvent{firstEvents, secondEvents} { + select { + case received := <-events: + if received.ID != note.ID || received.AuthorUserID != coInterviewer.ID { + t.Fatalf("subscriber %d received unexpected RabbitMQ event: %#v", index+1, received) + } + encoded, err := json.Marshal(received) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(encoded, []byte(`"id":`)) || bytes.Contains(encoded, []byte(`"noteId":`)) { + t.Fatalf("subscriber %d received an incompatible note contract: %s", index+1, encoded) + } + case <-time.After(5 * time.Second): + t.Fatalf("subscriber %d did not receive RabbitMQ note event", index+1) + } + } + var notes []webmodel.InterviewNoteResponse + requestJSON(t, http.MethodGet, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID), + login.Token, nil, http.StatusOK, ¬es) + if len(notes) != 1 || notes[0].Body != note.Body { + t.Fatalf("persisted interview note missing: %#v", notes) + } + + var meeting webmodel.ParticipantMeetingResponse + body := requestJSON(t, http.MethodGet, "/api/participant-meetings/"+scheduled.CandidateToken, + "", nil, http.StatusOK, &meeting) + if meeting.SharedDocument != "Collaborative notes" || + bytes.Contains(body, []byte("participantEmail")) || + bytes.Contains(body, []byte("referenceAnswer")) || + bytes.Contains(body, []byte(`"notes":`)) { + t.Fatalf("invalid candidate-safe meeting response: %s", body) + } } func requestJSON( diff --git a/backend/internal/security/security.go b/backend/internal/security/security.go index ce2f416..ecee9c5 100644 --- a/backend/internal/security/security.go +++ b/backend/internal/security/security.go @@ -1,6 +1,7 @@ package security import ( + "context" "errors" "fmt" "time" @@ -18,6 +19,22 @@ type Claims struct { jwt.RegisteredClaims } +type claimsContextKey struct{} + +func WithClaims(ctx context.Context, claims Claims) context.Context { + return context.WithValue(ctx, claimsContextKey{}, claims) +} + +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + claims, ok := ctx.Value(claimsContextKey{}).(Claims) + return claims, ok +} + +func UserIDFromContext(ctx context.Context) (uint, bool) { + claims, ok := ClaimsFromContext(ctx) + return claims.UserID, ok && claims.UserID != 0 +} + type ITokenManager interface { Issue(uint, string, string) (string, error) Parse(string) (Claims, error) diff --git a/backend/internal/service/interviews.go b/backend/internal/service/interviews.go new file mode 100644 index 0000000..6480626 --- /dev/null +++ b/backend/internal/service/interviews.go @@ -0,0 +1,261 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/eventbus" + "github.com/danyel/go-guess/backend/internal/security" + "github.com/danyel/go-guess/backend/internal/service/model" + "github.com/google/uuid" +) + +type IUserService interface { + List(context.Context) ([]model.User, error) + Create(context.Context, string, string, string) (model.User, error) +} + +type IScheduledInterviewService interface { + Create(context.Context, uint, uint, model.ScheduledInterview, []uint) (model.ScheduledInterview, error) + List(context.Context, uint, uint) ([]model.ScheduledInterview, error) + Get(context.Context, uint, uint) (model.ScheduledInterview, error) + GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error) + UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error) + UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error) + ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error) + CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error) + ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) + UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error) + ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) + Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) +} + +type UserService struct { + store repository.IStore +} + +func NewUserService(store repository.IStore) *UserService { + return &UserService{store: store} +} + +func (s *UserService) List(ctx context.Context) ([]model.User, error) { + return s.store.ListUsers(ctx) +} + +func (s *UserService) Create(ctx context.Context, email, password, displayName string) (model.User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + displayName = strings.TrimSpace(displayName) + if email == "" || displayName == "" || len(password) < 8 { + return model.User{}, fmt.Errorf("%w: email, displayName, and an 8-character password are required", ErrValidation) + } + passwordHash, err := security.HashPassword(password) + if err != nil { + return model.User{}, err + } + user, err := s.store.CreateUser(ctx, model.User{ + Email: email, PasswordHash: passwordHash, DisplayName: displayName, Role: "co_interviewer", + }) + if err != nil { + return model.User{}, err + } + user.PasswordHash = "" + return user, nil +} + +type ScheduledInterviewService struct { + store repository.IStore + bus eventbus.IEventBus +} + +func NewScheduledInterviewService(store repository.IStore, bus eventbus.IEventBus) *ScheduledInterviewService { + return &ScheduledInterviewService{store: store, bus: bus} +} + +func (s *ScheduledInterviewService) Create( + ctx context.Context, + userID, jobID uint, + value model.ScheduledInterview, + interviewerIDs []uint, +) (model.ScheduledInterview, error) { + invitation, err := s.store.GetInvitation(ctx, jobID, value.InvitationID) + if err != nil { + return model.ScheduledInterview{}, err + } + if invitation.Status != "completed" || invitation.Outcome != "passed" { + return model.ScheduledInterview{}, fmt.Errorf("%w: a passed completed assessment is required", ErrInvalidState) + } + value.Location = strings.TrimSpace(value.Location) + if value.StartsAt.IsZero() || value.Location == "" { + return model.ScheduledInterview{}, fmt.Errorf("%w: startsAt and location are required", ErrValidation) + } + users, err := s.store.ListUsers(ctx) + if err != nil { + return model.ScheduledInterview{}, err + } + validUsers := make(map[uint]struct{}, len(users)) + for _, user := range users { + validUsers[user.ID] = struct{}{} + } + unique := map[uint]struct{}{userID: {}} + for _, id := range interviewerIDs { + if _, ok := validUsers[id]; !ok { + return model.ScheduledInterview{}, fmt.Errorf("%w: interviewer %d does not exist", ErrValidation, id) + } + unique[id] = struct{}{} + } + ids := make([]uint, 0, len(unique)) + for id := range unique { + ids = append(ids, id) + } + value.JobID = jobID + value.ParticipantID = invitation.ParticipantID + value.CreatorID = userID + value.CandidateToken = uuid.NewString() + value.Status = "scheduled" + return s.store.CreateScheduledInterview(ctx, value, ids) +} + +func (s *ScheduledInterviewService) List( + ctx context.Context, userID, jobID uint, +) ([]model.ScheduledInterview, error) { + values, err := s.store.ListScheduledInterviews(ctx, jobID) + if err != nil { + return nil, err + } + result := make([]model.ScheduledInterview, 0, len(values)) + for _, value := range values { + if isAttendee(value, userID) { + result = append(result, value) + } + } + return result, nil +} + +func (s *ScheduledInterviewService) Get( + ctx context.Context, userID, interviewID uint, +) (model.ScheduledInterview, error) { + value, err := s.store.GetScheduledInterview(ctx, interviewID) + if err != nil { + return model.ScheduledInterview{}, err + } + if !isAttendee(value, userID) { + return model.ScheduledInterview{}, ErrForbidden + } + return value, nil +} + +func (s *ScheduledInterviewService) GetParticipantMeeting( + ctx context.Context, token string, +) (model.ScheduledInterview, error) { + return s.store.GetScheduledInterviewByToken(ctx, token) +} + +func (s *ScheduledInterviewService) UpdateStatus( + ctx context.Context, userID, interviewID uint, status string, +) (model.ScheduledInterview, error) { + current, err := s.Get(ctx, userID, interviewID) + if err != nil { + return model.ScheduledInterview{}, err + } + valid := (current.Status == "scheduled" && (status == "started" || status == "cancelled")) || + (current.Status == "started" && (status == "completed" || status == "cancelled")) + if !valid { + return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState) + } + return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status) +} + +func (s *ScheduledInterviewService) UpdateDocument( + ctx context.Context, userID, interviewID uint, document string, +) (model.ScheduledInterview, error) { + if _, err := s.Get(ctx, userID, interviewID); err != nil { + return model.ScheduledInterview{}, err + } + return s.store.UpdateScheduledInterviewDocument(ctx, interviewID, document) +} + +func (s *ScheduledInterviewService) ListNotes( + ctx context.Context, userID, interviewID uint, +) ([]model.InterviewNote, error) { + if _, err := s.Get(ctx, userID, interviewID); err != nil { + return nil, err + } + return s.store.ListInterviewNotes(ctx, interviewID) +} + +func (s *ScheduledInterviewService) CreateNote( + ctx context.Context, userID, interviewID uint, body string, +) (model.InterviewNote, error) { + interview, err := s.Get(ctx, userID, interviewID) + if err != nil { + return model.InterviewNote{}, err + } + if interview.Status != "started" { + return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState) + } + body = strings.TrimSpace(body) + if body == "" { + return model.InterviewNote{}, fmt.Errorf("%w: note body is required", ErrValidation) + } + note, err := s.store.CreateInterviewNote(ctx, model.InterviewNote{ + InterviewID: interviewID, AuthorID: userID, Body: body, + }) + if err != nil { + if errors.Is(err, repository.ErrInterviewNotStarted) { + return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState) + } + return model.InterviewNote{}, err + } + if err := s.bus.PublishInterviewNote(ctx, eventbus.InterviewNoteEvent{ + InterviewID: note.InterviewID, ID: note.ID, AuthorUserID: note.AuthorID, + AuthorName: note.Author.DisplayName, Body: note.Body, CreatedAt: note.CreatedAt, + }); err != nil { + return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err) + } + return note, nil +} + +func (s *ScheduledInterviewService) ListInbox( + ctx context.Context, userID uint, +) ([]model.ScheduledInterview, error) { + return s.store.ListInbox(ctx, userID) +} + +func (s *ScheduledInterviewService) UpdateInbox( + ctx context.Context, userID, interviewID uint, status string, +) (model.ScheduledInterview, error) { + if status != "accepted" && status != "declined" { + return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation) + } + if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil { + return model.ScheduledInterview{}, err + } + return s.Get(ctx, userID, interviewID) +} + +func (s *ScheduledInterviewService) ListCalendar( + ctx context.Context, userID uint, +) ([]model.ScheduledInterview, error) { + return s.store.ListCalendar(ctx, userID) +} + +func (s *ScheduledInterviewService) Subscribe( + ctx context.Context, userID, interviewID uint, +) (<-chan eventbus.InterviewNoteEvent, error) { + if _, err := s.Get(ctx, userID, interviewID); err != nil { + return nil, err + } + return s.bus.SubscribeInterviewNotes(ctx) +} + +func isAttendee(value model.ScheduledInterview, userID uint) bool { + for _, attendee := range value.Attendees { + if attendee.UserID == userID { + return true + } + } + return false +} diff --git a/backend/internal/service/model/models.go b/backend/internal/service/model/models.go index 30244b2..3cbc63b 100644 --- a/backend/internal/service/model/models.go +++ b/backend/internal/service/model/models.go @@ -6,6 +6,7 @@ type User struct { ID uint Email string PasswordHash string + DisplayName string Role string } @@ -69,12 +70,53 @@ type Invitation struct { ParticipantEmail string Token string Status string + Outcome string DurationMinutes int AcceptedAt *time.Time CompletedAt *time.Time CreatedAt time.Time } +type ScheduledInterview struct { + ID uint + JobID uint + InvitationID uint + ParticipantID uint + CreatorID uint + StartsAt time.Time + Location string + CandidateToken string + SharedDocument string + Status string + Job JobPosting + Invitation Invitation + Participant Participant + Creator User + Attendees []InterviewAttendee + Notes []InterviewNote + CreatedAt time.Time + UpdatedAt time.Time +} + +type InterviewAttendee struct { + ID uint + InterviewID uint + UserID uint + Status string + User User + CreatedAt time.Time + UpdatedAt time.Time +} + +type InterviewNote struct { + ID uint + InterviewID uint + AuthorID uint + Author User + Body string + CreatedAt time.Time +} + type Interview struct { Invitation Invitation Job JobPosting diff --git a/backend/internal/service/services.go b/backend/internal/service/services.go index 6789690..0fb2025 100644 --- a/backend/internal/service/services.go +++ b/backend/internal/service/services.go @@ -21,6 +21,7 @@ var ( ErrInvalidQuestion = errors.New("invalid question") ErrValidation = errors.New("validation failed") ErrInvalidState = errors.New("operation is not allowed in the current state") + ErrForbidden = errors.New("operation is forbidden") ) const CandidateThreshold = 60.0 @@ -60,6 +61,7 @@ type IInvitationService interface { Accept(context.Context, string) (model.Interview, error) SaveAnswer(context.Context, string, uint, string) error Finish(context.Context, string) (model.Interview, error) + SetOutcome(context.Context, uint, uint, string) (model.Invitation, error) } type AuthService struct { @@ -460,6 +462,25 @@ func (s *InvitationService) Finish(ctx context.Context, token string) (model.Int return s.GetInterview(ctx, token) } +func (s *InvitationService) SetOutcome( + ctx context.Context, jobID, invitationID uint, outcome string, +) (model.Invitation, error) { + switch outcome { + case "pending": + case "passed", "failed": + invitation, err := s.store.GetInvitation(ctx, jobID, invitationID) + if err != nil { + return model.Invitation{}, err + } + if invitation.Status != "completed" { + return model.Invitation{}, fmt.Errorf("%w: assessment must be completed", ErrInvalidState) + } + default: + return model.Invitation{}, fmt.Errorf("%w: unsupported assessment outcome", ErrValidation) + } + return s.store.UpdateInvitationOutcome(ctx, jobID, invitationID, outcome) +} + 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)) diff --git a/backend/internal/service/services_test.go b/backend/internal/service/services_test.go index d25abb0..e83d412 100644 --- a/backend/internal/service/services_test.go +++ b/backend/internal/service/services_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/danyel/go-guess/backend/internal/database/repository" + "github.com/danyel/go-guess/backend/internal/eventbus" + "github.com/danyel/go-guess/backend/internal/security" "github.com/danyel/go-guess/backend/internal/service/model" ) @@ -14,6 +16,7 @@ type fakeStore struct { job model.JobPosting participants []model.Participant traits []model.Trait + createdUser model.User } type invitationStore struct { @@ -23,6 +26,58 @@ type invitationStore struct { saved bool } +type scheduledStore struct { + *fakeStore + invitation model.Invitation + interview model.ScheduledInterview + note model.InterviewNote + createdIDs []uint + status string + document string +} + +func (f *scheduledStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) { + return f.invitation, nil +} +func (f *scheduledStore) ListUsers(context.Context) ([]model.User, error) { + return []model.User{{ID: 1}, {ID: 2}}, nil +} +func (f *scheduledStore) CreateScheduledInterview( + _ context.Context, value model.ScheduledInterview, ids []uint, +) (model.ScheduledInterview, error) { + f.createdIDs = ids + value.ID = 9 + value.Attendees = []model.InterviewAttendee{{UserID: value.CreatorID, Status: "accepted"}} + f.interview = value + return value, nil +} +func (f *scheduledStore) GetScheduledInterview(context.Context, uint) (model.ScheduledInterview, error) { + return f.interview, nil +} +func (f *scheduledStore) UpdateScheduledInterviewStatus( + _ context.Context, _ uint, status string, +) (model.ScheduledInterview, error) { + f.status = status + f.interview.Status = status + return f.interview, nil +} +func (f *scheduledStore) UpdateScheduledInterviewDocument( + _ context.Context, _ uint, document string, +) (model.ScheduledInterview, error) { + f.document = document + f.interview.SharedDocument = document + return f.interview, nil +} +func (f *scheduledStore) CreateInterviewNote( + _ context.Context, value model.InterviewNote, +) (model.InterviewNote, error) { + value.ID = 4 + value.Author = model.User{ID: value.AuthorID, DisplayName: "Ada"} + value.CreatedAt = time.Now() + f.note = value + return value, nil +} + func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) { value.ID = 1 return value, nil @@ -41,6 +96,11 @@ func (f *invitationStore) ListInvitations(context.Context, uint) ([]model.Invita func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) { return model.User{}, errors.New("unused") } +func (f *fakeStore) ListUsers(context.Context) ([]model.User, error) { return nil, nil } +func (f *fakeStore) CreateUser(_ context.Context, value model.User) (model.User, error) { + f.createdUser = value + return value, nil +} 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) { @@ -90,6 +150,57 @@ func (f *fakeStore) SaveAnswer(context.Context, string, uint, string) error { func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error { return nil } +func (f *fakeStore) GetInvitation(context.Context, uint, uint) (model.Invitation, error) { + return model.Invitation{}, nil +} +func (f *fakeStore) UpdateInvitationOutcome( + _ context.Context, _, _ uint, outcome string, +) (model.Invitation, error) { + return model.Invitation{Outcome: outcome}, nil +} +func (f *fakeStore) CreateScheduledInterview( + _ context.Context, value model.ScheduledInterview, _ []uint, +) (model.ScheduledInterview, error) { + return value, nil +} +func (f *fakeStore) ListScheduledInterviews(context.Context, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} +func (f *fakeStore) GetScheduledInterview(context.Context, uint) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (f *fakeStore) GetScheduledInterviewByToken(context.Context, string) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (f *fakeStore) UpdateScheduledInterviewStatus( + _ context.Context, _ uint, _ string, +) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (f *fakeStore) UpdateScheduledInterviewDocument( + _ context.Context, _ uint, _ string, +) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (f *fakeStore) ListInterviewNotes(context.Context, uint) ([]model.InterviewNote, error) { + return nil, nil +} +func (f *fakeStore) CreateInterviewNote( + _ context.Context, value model.InterviewNote, +) (model.InterviewNote, error) { + return value, nil +} +func (f *fakeStore) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} +func (f *fakeStore) UpdateAttendeeStatus( + _ context.Context, _, _ uint, _ string, +) (model.InterviewAttendee, error) { + return model.InterviewAttendee{}, nil +} +func (f *fakeStore) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} func TestCandidatesRequireSixtyPercent(t *testing.T) { store := &fakeStore{ @@ -207,3 +318,107 @@ func TestInvitationReviewIsScopedToJob(t *testing.T) { t.Fatalf("expected missing invitation error, got %v", err) } } + +func TestInvitationOutcomeRequiresCompletedAssessment(t *testing.T) { + store := &scheduledStore{ + fakeStore: &fakeStore{}, invitation: model.Invitation{Status: "accepted"}, + } + invitations := NewInvitationService(store, "") + if _, err := invitations.SetOutcome(context.Background(), 1, 2, "passed"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("expected incomplete assessment rejection, got %v", err) + } + store.invitation.Status = "completed" + outcome, err := invitations.SetOutcome(context.Background(), 1, 2, "passed") + if err != nil || outcome.Outcome != "passed" { + t.Fatalf("expected passed outcome, got %#v, %v", outcome, err) + } +} + +func TestScheduledInterviewRequiresPassedAssessmentAndAddsCreator(t *testing.T) { + store := &scheduledStore{ + fakeStore: &fakeStore{}, + invitation: model.Invitation{ + Status: "completed", Outcome: "passed", ParticipantID: 7, + }, + } + interviews := NewScheduledInterviewService(store, eventbus.NewMemoryBus()) + created, err := interviews.Create(context.Background(), 1, 5, model.ScheduledInterview{ + InvitationID: 3, StartsAt: time.Now().Add(time.Hour), Location: "Room 4", + }, []uint{2}) + if err != nil { + t.Fatal(err) + } + if created.CreatorID != 1 || created.ParticipantID != 7 || created.CandidateToken == "" || + len(store.createdIDs) != 2 { + t.Fatalf("unexpected scheduled interview: %#v ids=%v", created, store.createdIDs) + } + store.invitation.Outcome = "failed" + if _, err := interviews.Create(context.Background(), 1, 5, model.ScheduledInterview{ + InvitationID: 3, StartsAt: time.Now(), Location: "Room 4", + }, nil); !errors.Is(err, ErrInvalidState) { + t.Fatalf("expected failed assessment rejection, got %v", err) + } +} + +func TestScheduledInterviewAuthorizationAndState(t *testing.T) { + store := &scheduledStore{ + fakeStore: &fakeStore{}, + interview: model.ScheduledInterview{ + ID: 9, Status: "scheduled", + Attendees: []model.InterviewAttendee{{UserID: 1, Status: "accepted"}}, + }, + } + interviews := NewScheduledInterviewService(store, eventbus.NewMemoryBus()) + if _, err := interviews.Get(context.Background(), 2, 9); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected forbidden, got %v", err) + } + if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "completed"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("expected invalid transition, got %v", err) + } + if _, err := interviews.UpdateStatus(context.Background(), 1, 9, "started"); err != nil || store.status != "started" { + t.Fatalf("expected started transition, got %v", err) + } + if _, err := interviews.UpdateDocument(context.Background(), 2, 9, "secret"); !errors.Is(err, ErrForbidden) { + t.Fatalf("expected document authorization, got %v", err) + } +} + +func TestCreateNotePersistsThenPublishesSafeEvent(t *testing.T) { + store := &scheduledStore{ + fakeStore: &fakeStore{}, + interview: model.ScheduledInterview{ + ID: 9, Status: "started", Attendees: []model.InterviewAttendee{{UserID: 1}}, + }, + } + bus := eventbus.NewMemoryBus() + events, err := bus.SubscribeInterviewNotes(context.Background()) + if err != nil { + t.Fatal(err) + } + interviews := NewScheduledInterviewService(store, bus) + note, err := interviews.CreateNote(context.Background(), 1, 9, " useful note ") + if err != nil { + t.Fatal(err) + } + event := <-events + if note.Body != "useful note" || event.ID != note.ID || event.AuthorName != "Ada" { + t.Fatalf("unexpected note/event: %#v %#v", note, event) + } +} + +func TestUserCreationHashesPasswordAndUsesCoInterviewerRole(t *testing.T) { + store := &fakeStore{} + user, err := NewUserService(store).Create(context.Background(), " CO@Example.com ", "password1", " Ada ") + if err != nil { + t.Fatal(err) + } + if user.Email != "co@example.com" || user.DisplayName != "Ada" || user.Role != "co_interviewer" { + t.Fatalf("unexpected user: %#v", user) + } + if user.PasswordHash != "" { + t.Fatal("created user exposed password hash") + } + if !security.CheckPassword(store.createdUser.PasswordHash, "password1") { + t.Fatal("password was not bcrypt hashed before persistence") + } +} diff --git a/backend/internal/web/handler/handler.go b/backend/internal/web/handler/handler.go index 30b0d33..9e541df 100644 --- a/backend/internal/web/handler/handler.go +++ b/backend/internal/web/handler/handler.go @@ -15,6 +15,7 @@ import ( "github.com/go-chi/chi/v5" "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" servicemodel "github.com/danyel/go-guess/backend/internal/service/model" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" @@ -27,6 +28,8 @@ type Handler struct { questions service.IQuestionService participants service.IParticipantService invitations service.IInvitationService + users service.IUserService + interviews service.IScheduledInterviewService maxUpload int64 frontendURL string } @@ -37,12 +40,15 @@ func New( questions service.IQuestionService, participants service.IParticipantService, invitations service.IInvitationService, + users service.IUserService, + interviews service.IScheduledInterviewService, maxUploadMB int64, frontendURL string, ) *Handler { return &Handler{ auth: auth, jobs: jobs, questions: questions, participants: participants, invitations: invitations, maxUpload: maxUploadMB << 20, + users: users, interviews: interviews, frontendURL: strings.TrimRight(frontendURL, "/"), } } @@ -67,10 +73,36 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, http.StatusOK, webmodel.LoginResponse{ - Token: token, User: webmodel.User{ID: user.ID, Email: user.Email, Role: user.Role}, + Token: token, User: webmapper.UserToWeb(user), }) } +func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { + values, err := h.users.List(r.Context()) + if err != nil { + internalError(w, r, err) + return + } + response := make([]webmodel.User, len(values)) + for i, value := range values { + response[i] = webmapper.UserToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { + var request webmodel.CreateUserRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.users.Create(r.Context(), request.Email, request.Password, request.DisplayName) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusCreated, webmapper.UserToWeb(value)) +} + func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) { values, err := h.jobs.List(r.Context()) if err != nil { @@ -325,6 +357,193 @@ func (h *Handler) ReviewInvitation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, webmapper.InvitationReviewToWeb(value, h.frontendURL)) } +func (h *Handler) SetInvitationOutcome(w http.ResponseWriter, r *http.Request) { + var request webmodel.InvitationOutcomeRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.invitations.SetOutcome( + r.Context(), idParam(r), secondaryIDParam(r, "invitationId"), request.Outcome, + ) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.InvitationToWeb(value, h.frontendURL)) +} + +func (h *Handler) CreateScheduledInterview(w http.ResponseWriter, r *http.Request) { + var request webmodel.CreateScheduledInterviewRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.interviews.Create(r.Context(), currentUserID(r), idParam(r), servicemodel.ScheduledInterview{ + InvitationID: request.InvitationID, StartsAt: request.StartsAt, + Location: request.Location, SharedDocument: request.SharedDocument, + }, request.InterviewerIDs) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusCreated, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) ListScheduledInterviews(w http.ResponseWriter, r *http.Request) { + values, err := h.interviews.List(r.Context(), currentUserID(r), idParam(r)) + if handleServiceError(w, r, err) { + return + } + response := make([]webmodel.ScheduledInterviewResponse, len(values)) + for i, value := range values { + response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetScheduledInterview(w http.ResponseWriter, r *http.Request) { + value, err := h.interviews.Get(r.Context(), currentUserID(r), idParam(r)) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) UpdateScheduledInterviewStatus(w http.ResponseWriter, r *http.Request) { + var request webmodel.InterviewStatusRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.interviews.UpdateStatus(r.Context(), currentUserID(r), idParam(r), request.Status) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) UpdateScheduledInterviewDocument(w http.ResponseWriter, r *http.Request) { + var request webmodel.InterviewDocumentRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.interviews.UpdateDocument( + r.Context(), currentUserID(r), idParam(r), request.SharedDocument, + ) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) ListInterviewNotes(w http.ResponseWriter, r *http.Request) { + values, err := h.interviews.ListNotes(r.Context(), currentUserID(r), idParam(r)) + if handleServiceError(w, r, err) { + return + } + response := make([]webmodel.InterviewNoteResponse, len(values)) + for i, value := range values { + response[i] = webmapper.InterviewNoteToWeb(value) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) CreateInterviewNote(w http.ResponseWriter, r *http.Request) { + var request webmodel.InterviewNoteRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.interviews.CreateNote(r.Context(), currentUserID(r), idParam(r), request.Body) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusCreated, webmapper.InterviewNoteToWeb(value)) +} + +func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) { + values, err := h.interviews.ListInbox(r.Context(), currentUserID(r)) + if handleServiceError(w, r, err) { + return + } + response := make([]webmodel.ScheduledInterviewResponse, len(values)) + for i, value := range values { + response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) UpdateInbox(w http.ResponseWriter, r *http.Request) { + var request webmodel.AttendeeStatusRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + value, err := h.interviews.UpdateInbox(r.Context(), currentUserID(r), idParam(r), request.Status) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ScheduledInterviewToWeb(value, h.frontendURL)) +} + +func (h *Handler) ListCalendar(w http.ResponseWriter, r *http.Request) { + values, err := h.interviews.ListCalendar(r.Context(), currentUserID(r)) + if handleServiceError(w, r, err) { + return + } + response := make([]webmodel.ScheduledInterviewResponse, len(values)) + for i, value := range values { + response[i] = webmapper.ScheduledInterviewToWeb(value, h.frontendURL) + } + writeJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetParticipantMeeting(w http.ResponseWriter, r *http.Request) { + value, err := h.interviews.GetParticipantMeeting(r.Context(), chi.URLParam(r, "token")) + if handleServiceError(w, r, err) { + return + } + writeJSON(w, http.StatusOK, webmapper.ParticipantMeetingToWeb(value)) +} + +func (h *Handler) InterviewEvents(w http.ResponseWriter, r *http.Request) { + events, err := h.interviews.Subscribe(r.Context(), currentUserID(r), idParam(r)) + if handleServiceError(w, r, err) { + return + } + flusher, ok := w.(http.Flusher) + if !ok { + internalError(w, r, errors.New("streaming is unsupported")) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + for { + select { + case <-r.Context().Done(): + return + case event, ok := <-events: + if !ok { + return + } + if event.InterviewID != idParam(r) { + continue + } + payload, err := json.Marshal(event) + if err != nil { + return + } + if _, err := fmt.Fprintf(w, "event: interview-note\ndata: %s\n\n", payload); err != nil { + return + } + flusher.Flush() + } + } +} + 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) { @@ -422,12 +641,19 @@ func handleServiceError(w http.ResponseWriter, r *http.Request, err error) bool writeError(w, http.StatusConflict, err) case errors.Is(err, service.ErrNotAvailable): writeError(w, http.StatusNotFound, err) + case errors.Is(err, service.ErrForbidden): + writeError(w, http.StatusForbidden, err) default: internalError(w, r, err) } return true } +func currentUserID(r *http.Request) uint { + id, _ := security.UserIDFromContext(r.Context()) + return id +} + 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")) diff --git a/backend/internal/web/mapper/mapper.go b/backend/internal/web/mapper/mapper.go index 67fad0e..8ce0e12 100644 --- a/backend/internal/web/mapper/mapper.go +++ b/backend/internal/web/mapper/mapper.go @@ -85,11 +85,64 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID, ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail, Token: value.Token, Status: value.Status, + Outcome: value.Outcome, ParticipantURL: frontendURL + "/participant/" + value.Token, AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, } } +func UserToWeb(value servicemodel.User) webmodel.User { + return webmodel.User{ + ID: value.ID, Email: value.Email, DisplayName: value.DisplayName, Role: value.Role, + } +} + +func ScheduledInterviewToWeb( + value servicemodel.ScheduledInterview, frontendURL string, +) webmodel.ScheduledInterviewResponse { + attendees := make([]webmodel.InterviewAttendeeResponse, len(value.Attendees)) + for i, attendee := range value.Attendees { + attendees[i] = InterviewAttendeeToWeb(attendee) + } + notes := make([]webmodel.InterviewNoteResponse, len(value.Notes)) + for i, note := range value.Notes { + notes[i] = InterviewNoteToWeb(note) + } + return webmodel.ScheduledInterviewResponse{ + ID: value.ID, JobID: value.JobID, JobTitle: value.Job.Title, + ParticipantID: value.ParticipantID, + ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName, + StartsAt: value.StartsAt, Location: value.Location, CandidateToken: value.CandidateToken, + CandidateURL: frontendURL + "/participant/meeting/" + value.CandidateToken, + SharedDocument: value.SharedDocument, Status: value.Status, + Attendees: attendees, Notes: notes, + CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, + } +} + +func ParticipantMeetingToWeb(value servicemodel.ScheduledInterview) webmodel.ParticipantMeetingResponse { + return webmodel.ParticipantMeetingResponse{ + ID: value.ID, JobID: value.JobID, JobTitle: value.Job.Title, + ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName, + StartsAt: value.StartsAt, Location: value.Location, + SharedDocument: value.SharedDocument, Status: value.Status, + } +} + +func InterviewAttendeeToWeb(value servicemodel.InterviewAttendee) webmodel.InterviewAttendeeResponse { + return webmodel.InterviewAttendeeResponse{ + UserID: value.UserID, DisplayName: value.User.DisplayName, + Email: value.User.Email, Status: value.Status, + } +} + +func InterviewNoteToWeb(value servicemodel.InterviewNote) webmodel.InterviewNoteResponse { + return webmodel.InterviewNoteResponse{ + ID: value.ID, AuthorUserID: value.AuthorID, AuthorName: value.Author.DisplayName, + Body: value.Body, 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 { diff --git a/backend/internal/web/model/models.go b/backend/internal/web/model/models.go index f762501..9d48908 100644 --- a/backend/internal/web/model/models.go +++ b/backend/internal/web/model/models.go @@ -17,9 +17,16 @@ type LoginResponse struct { } type User struct { - ID uint `json:"id"` - Email string `json:"email"` - Role string `json:"role"` + ID uint `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` +} + +type CreateUserRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"displayName"` } type JobRequest struct { @@ -108,6 +115,7 @@ type InvitationResponse struct { ParticipantEmail string `json:"participantEmail"` Token string `json:"token"` Status string `json:"status"` + Outcome string `json:"outcome"` ParticipantURL string `json:"participantUrl"` AcceptedAt *time.Time `json:"acceptedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"` @@ -147,3 +155,75 @@ type InvitationReviewResponse struct { type AnswerRequest struct { Answer string `json:"answer"` } + +type InvitationOutcomeRequest struct { + Outcome string `json:"outcome"` +} + +type CreateScheduledInterviewRequest struct { + InvitationID uint `json:"invitationId"` + StartsAt time.Time `json:"startsAt"` + Location string `json:"location"` + InterviewerIDs []uint `json:"interviewerIds"` + SharedDocument string `json:"sharedDocument"` +} + +type InterviewStatusRequest struct { + Status string `json:"status"` +} + +type InterviewDocumentRequest struct { + SharedDocument string `json:"sharedDocument"` +} + +type InterviewNoteRequest struct { + Body string `json:"body"` +} + +type AttendeeStatusRequest struct { + Status string `json:"status"` +} + +type InterviewAttendeeResponse struct { + UserID uint `json:"userId"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Status string `json:"status"` +} + +type ScheduledInterviewResponse struct { + ID uint `json:"id"` + JobID uint `json:"jobId"` + JobTitle string `json:"jobTitle"` + ParticipantID uint `json:"participantId"` + ParticipantName string `json:"participantName"` + StartsAt time.Time `json:"startsAt"` + Location string `json:"location"` + CandidateToken string `json:"candidateToken"` + CandidateURL string `json:"candidateUrl"` + SharedDocument string `json:"sharedDocument"` + Status string `json:"status"` + Attendees []InterviewAttendeeResponse `json:"attendees"` + Notes []InterviewNoteResponse `json:"notes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ParticipantMeetingResponse struct { + ID uint `json:"id"` + JobID uint `json:"jobId"` + JobTitle string `json:"jobTitle"` + ParticipantName string `json:"participantName"` + StartsAt time.Time `json:"startsAt"` + Location string `json:"location"` + SharedDocument string `json:"sharedDocument"` + Status string `json:"status"` +} + +type InterviewNoteResponse struct { + ID uint `json:"id"` + AuthorUserID uint `json:"authorUserId"` + AuthorName string `json:"authorName"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 9cac624..fe59852 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -1,7 +1,6 @@ package router import ( - "context" "encoding/json" "net/http" "strings" @@ -13,10 +12,6 @@ import ( "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)) @@ -27,6 +22,7 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) 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.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting) router.Group(func(protected chi.Router) { protected.Use(authenticate(tokens)) protected.Get("/api/jobs", h.ListJobs) @@ -39,6 +35,20 @@ func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) protected.Get("/api/jobs/{id}/invitations", h.ListInvitations) protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation) + protected.Patch("/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome) + protected.Get("/api/jobs/{id}/interviews", h.ListScheduledInterviews) + protected.Post("/api/jobs/{id}/interviews", h.CreateScheduledInterview) + protected.Get("/api/scheduled-interviews/{id}", h.GetScheduledInterview) + protected.Patch("/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus) + protected.Patch("/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument) + protected.Get("/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes) + protected.Post("/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote) + protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents) + protected.Get("/api/users", h.ListUsers) + protected.Post("/api/users", h.CreateUser) + protected.Get("/api/inbox", h.ListInbox) + protected.Patch("/api/inbox/{id}", h.UpdateInbox) + protected.Get("/api/calendar", h.ListCalendar) protected.Get("/api/questions", h.ListQuestions) protected.Post("/api/questions", h.CreateQuestion) protected.Put("/api/questions/{id}", h.UpdateQuestion) @@ -72,7 +82,7 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) + next.ServeHTTP(w, r.WithContext(security.WithClaims(r.Context(), claims))) }) } } diff --git a/backend/internal/web/router/router_test.go b/backend/internal/web/router/router_test.go index 031fdad..9a70111 100644 --- a/backend/internal/web/router/router_test.go +++ b/backend/internal/web/router/router_test.go @@ -1,12 +1,15 @@ package router import ( + "bytes" "context" + "encoding/json" "net/http" "net/http/httptest" "testing" "time" + "github.com/danyel/go-guess/backend/internal/eventbus" "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" @@ -77,9 +80,87 @@ func (invitationStub) SaveAnswer(context.Context, string, uint, string) error { func (invitationStub) Finish(context.Context, string) (model.Interview, error) { return model.Interview{}, nil } +func (invitationStub) SetOutcome(context.Context, uint, uint, string) (model.Invitation, error) { + return model.Invitation{}, nil +} + +type userStub struct{} + +func (userStub) List(context.Context) ([]model.User, error) { return nil, nil } +func (userStub) Create(context.Context, string, string, string) (model.User, error) { + return model.User{}, nil +} + +type interviewStub struct{} + +func (interviewStub) Create( + context.Context, uint, uint, model.ScheduledInterview, []uint, +) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) List(context.Context, uint, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} +func (interviewStub) Get(context.Context, uint, uint) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error) { + return nil, nil +} +func (interviewStub) CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error) { + return model.InterviewNote{}, nil +} +func (interviewStub) ListInbox(context.Context, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} +func (interviewStub) UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error) { + return model.ScheduledInterview{}, nil +} +func (interviewStub) ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error) { + return nil, nil +} +func (interviewStub) Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error) { + return make(chan eventbus.InterviewNoteEvent), nil +} + +type interviewCapture struct { + interviewStub + userID uint +} + +func (s *interviewCapture) Get( + _ context.Context, userID, interviewID uint, +) (model.ScheduledInterview, error) { + s.userID = userID + return model.ScheduledInterview{ + ID: interviewID, + Attendees: []model.InterviewAttendee{{ + ID: 3, UserID: userID, User: model.User{ID: userID, DisplayName: "Interviewer"}, + }}, + }, nil +} + +func (s *interviewCapture) GetParticipantMeeting( + context.Context, string, +) (model.ScheduledInterview, error) { + return model.ScheduledInterview{ + ID: 4, JobID: 5, Job: model.JobPosting{Title: "Backend Engineer"}, + Participant: model.Participant{FirstName: "Candidate", LastName: "Person", Email: "private@example.com"}, + SharedDocument: "candidate document", + }, nil +} func TestHealthDoesNotRequireAuthentication(t *testing.T) { - h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173") + h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 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) @@ -91,7 +172,7 @@ func TestHealthDoesNotRequireAuthentication(t *testing.T) { } func TestProtectedRouteRequiresValidToken(t *testing.T) { - h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, 10, "http://localhost:5173") + h := handler.New(authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, userStub{}, interviewStub{}, 10, "http://localhost:5173") tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) router := New(h, tokens, "http://localhost:5173") @@ -100,7 +181,6 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) { 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) @@ -113,3 +193,48 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) { t.Fatalf("expected 200, got %d", authorized.Code) } } + +func TestProtectedInterviewUsesAuthenticatedUserID(t *testing.T) { + interviews := &interviewCapture{} + h := handler.New( + authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, + userStub{}, interviews, 10, "http://localhost:5173", + ) + tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour) + api := New(h, tokens, "http://localhost:5173") + token, err := tokens.Issue(42, "co@example.com", "co_interviewer") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/scheduled-interviews/9", nil) + request.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + api.ServeHTTP(response, request) + if response.Code != http.StatusOK || interviews.userID != 42 { + t.Fatalf("expected authenticated user 42, got status=%d user=%d", response.Code, interviews.userID) + } +} + +func TestParticipantMeetingDoesNotExposeInternalCandidateData(t *testing.T) { + interviews := &interviewCapture{} + h := handler.New( + authStub{}, jobStub{}, questionStub{}, participantStub{}, invitationStub{}, + userStub{}, interviews, 10, "http://localhost:5173", + ) + api := New(h, security.NewTokenManager("01234567890123456789012345678901", time.Hour), "http://localhost:5173") + response := httptest.NewRecorder() + api.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/participant-meetings/token", nil)) + if response.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", response.Code) + } + body := response.Body.Bytes() + if bytes.Contains(body, []byte("private@example.com")) || + bytes.Contains(body, []byte("referenceAnswer")) || + bytes.Contains(body, []byte("notes")) { + t.Fatalf("public response leaked internal data: %s", body) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil || payload["sharedDocument"] != "candidate document" { + t.Fatalf("unexpected public response: %s", body) + } +} diff --git a/backend/migrations/00006_next_stage_interviews.sql b/backend/migrations/00006_next_stage_interviews.sql new file mode 100644 index 0000000..8695187 --- /dev/null +++ b/backend/migrations/00006_next_stage_interviews.sql @@ -0,0 +1,60 @@ +-- +goose Up +ALTER TABLE users + ADD COLUMN display_name VARCHAR(200) NOT NULL DEFAULT ''; + +UPDATE users +SET display_name = split_part(email, '@', 1) +WHERE display_name = ''; + +ALTER TABLE invitations + ADD COLUMN outcome VARCHAR(32) NOT NULL DEFAULT 'pending' + CHECK (outcome IN ('pending', 'passed', 'failed')); + +CREATE TABLE scheduled_interviews ( + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE, + invitation_id BIGINT NOT NULL UNIQUE REFERENCES invitations(id) ON DELETE RESTRICT, + participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE RESTRICT, + creator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + starts_at TIMESTAMPTZ NOT NULL, + location VARCHAR(500) NOT NULL, + candidate_token VARCHAR(64) NOT NULL UNIQUE, + shared_document TEXT NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'scheduled' + CHECK (status IN ('scheduled', 'started', 'completed', 'cancelled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX scheduled_interviews_job_id_idx ON scheduled_interviews(job_id); +CREATE INDEX scheduled_interviews_participant_id_idx ON scheduled_interviews(participant_id); + +CREATE TABLE interview_attendees ( + id BIGSERIAL PRIMARY KEY, + interview_id BIGINT NOT NULL REFERENCES scheduled_interviews(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(32) NOT NULL DEFAULT 'invited' + CHECK (status IN ('invited', 'accepted', 'declined')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (interview_id, user_id) +); + +CREATE INDEX interview_attendees_user_id_idx ON interview_attendees(user_id); + +CREATE TABLE interview_notes ( + id BIGSERIAL PRIMARY KEY, + interview_id BIGINT NOT NULL REFERENCES scheduled_interviews(id) ON DELETE CASCADE, + author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX interview_notes_interview_id_idx ON interview_notes(interview_id); + +-- +goose Down +DROP TABLE IF EXISTS interview_notes; +DROP TABLE IF EXISTS interview_attendees; +DROP TABLE IF EXISTS scheduled_interviews; +ALTER TABLE invitations DROP COLUMN outcome; +ALTER TABLE users DROP COLUMN display_name; diff --git a/docker-compose.yml b/docker-compose.yml index 05cf9e6..a6053c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,17 @@ services: timeout: 3s retries: 20 + rabbitmq: + image: rabbitmq:4-management-alpine + ports: + - "${RABBITMQ_AMQP_PORT:-5672}:5672" + - "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 20 + api: build: ./backend environment: @@ -22,11 +33,14 @@ services: JWT_SECRET: local-development-secret-change-me-12345 FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} APP_ENV: ${APP_ENV:-development} + RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/ ports: - "${API_PORT:-8080}:8080" depends_on: db: condition: service_healthy + rabbitmq: + condition: service_healthy web: build: ./frontend diff --git a/frontend/README.md b/frontend/README.md index bdeeab2..b5aff32 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -37,6 +37,8 @@ Supported routes: - `GET /api/jobs/:id/candidates` - `GET|POST /api/jobs/:id/invitations` - `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review +- `PATCH /api/jobs/:jobId/invitations/:invitationId/outcome` +- `GET|POST /api/jobs/:jobId/interviews` - `GET|POST /api/questions` (`GET` accepts `?search=`) - `PUT /api/questions/:id` to update question text, type, options, reference answer, and code snippet - `PATCH /api/questions/:id` to deprecate or restore a question @@ -48,6 +50,15 @@ Supported routes: - `POST /api/interviews/:token/accept` - `PUT /api/interviews/:token/answers/:questionId` - `POST /api/interviews/:token/finish` +- `GET|POST /api/users` +- `GET /api/inbox` and `PATCH /api/inbox/:interviewId` +- `GET /api/calendar` +- `GET /api/scheduled-interviews/:id` +- `PATCH /api/scheduled-interviews/:id/status` +- `PATCH /api/scheduled-interviews/:id/document` +- `GET|POST /api/scheduled-interviews/:id/notes` +- `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE) +- `GET /api/participant-meetings/:token` (public) Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`, `contactInfo`, `photo`, and `cv`. @@ -64,6 +75,18 @@ rendered in the participant interview. Code-review questions also require a code Accepted and completed invitations can be opened from the job to review every participant answer, possible choice options, reference answers, and code-review context; unanswered questions are called out explicitly. +Completed assessments can be marked as passed or failed. Passed candidates expose a scheduling +form for the date, location, co-interviewers, and candidate-visible shared documentation. + +The authenticated workspace also includes: + +- **Users** for listing and creating co-interviewers. +- **Inbox** for accepting or declining interview assignments. +- **Calendar** for interviews grouped by date. +- **Interviewer sessions** for changing status, editing prominent shared documentation, and + posting private notes while an interview is started. Notes arrive live through an authenticated + streaming `fetch`; the app intentionally does not use `EventSource`, because the request needs + the JWT authorization header. Participants open the generated `/participant/:invitationId` URL without signing in. The opaque invitation ID authorizes the existing `/api/interviews/:token` API. Code-review questions are shown @@ -72,6 +95,10 @@ between questions and as they change, then **Submit** completes the interview. T derived from the backend `acceptedAt` timestamp plus the job duration so refreshing cannot reset it. Questions stay hidden until the participant explicitly accepts the invitation. +Scheduled candidates use `/participant/meeting/:token`. This public view shows the interview date, +location, status, and read-only shared documentation. It never renders interviewer notes or +assessment reference answers. + ## Container Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 59b0037..cd10d08 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -58,7 +58,7 @@ function mockApi() { if (path === '/api/auth/login') return response({ token: 'jwt-token', - user: { id: 1, email: 'admin@example.com', role: 'admin' }, + user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' }, }) if (path === '/api/jobs') return response([job]) if (path === '/api/participants') return response([participant]) @@ -73,6 +73,7 @@ function mockApi() { }, ]) if (path === '/api/jobs/7/invitations' && method === 'GET') return response([]) + if (path === '/api/jobs/7/interviews' && method === 'GET') return response([]) if (path === '/api/jobs/7/invitations' && method === 'POST') return response({ id: 31, @@ -110,7 +111,12 @@ function authenticate() { sessionStorage.setItem('go-guess-token', 'jwt-token') sessionStorage.setItem( 'go-guess-user', - JSON.stringify({ id: 1, email: 'admin@example.com', role: 'admin' }), + JSON.stringify({ + id: 1, + email: 'admin@example.com', + displayName: 'Admin', + role: 'admin', + }), ) } @@ -570,4 +576,331 @@ describe('Go Guess frontend', () => { expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument() }) + + it('records pass and fail decisions from a completed answer review', async () => { + authenticate() + const user = userEvent.setup() + const completed = { + id: 44, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'review-token', + status: 'completed', + outcome: 'pending', + participantUrl: '/participant/review-token', + acceptedAt: '2026-09-19T10:05:00Z', + completedAt: '2026-09-19T10:15:00Z', + createdAt: '2026-09-19T10:00:00Z', + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path === '/api/jobs/7/invitations' && method === 'GET') return response([completed]) + if (path === '/api/jobs/7/invitations/44' && method === 'GET') + return response({ + invitation: completed, + job: { ...job, questions: [question] }, + answers: { '11': 'Retries with jitter.' }, + }) + if (path === '/api/jobs/7/invitations/44/outcome' && method === 'PATCH') + return response({ + ...completed, + outcome: JSON.parse(String(options?.body)).outcome, + }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/jobs/7') + await user.click(await screen.findByRole('tab', { name: /invitations/i })) + await user.click(screen.getByRole('button', { name: 'Review answers' })) + await user.click(await screen.findByRole('button', { name: 'Pass candidate' })) + await user.click(screen.getByRole('button', { name: 'Fail candidate' })) + + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/invitations/44/outcome', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'passed' }) }), + ) + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/invitations/44/outcome', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ outcome: 'failed' }) }), + ) + }) + + it('creates an interview from a passed invitation with co-interviewers and shared documentation', async () => { + authenticate() + const user = userEvent.setup() + const passed = { + id: 45, + jobId: 7, + participantId: 3, + participantName: 'Amélie Dubois', + participantEmail: 'amelie@example.com', + token: 'passed-token', + status: 'completed', + outcome: 'passed', + participantUrl: '/participant/passed-token', + acceptedAt: '2026-09-19T10:05:00Z', + completedAt: '2026-09-19T10:15:00Z', + createdAt: '2026-09-19T10:00:00Z', + } + const interviewer = { + id: 2, + email: 'alex@example.com', + displayName: 'Alex Morgan', + role: 'interviewer', + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path === '/api/jobs/7/invitations' && method === 'GET') return response([passed]) + if (path === '/api/users') return response([interviewer]) + if (path === '/api/jobs/7/interviews' && method === 'POST') + return response({ + id: 80, + jobId: 7, + jobTitle: job.title, + participantId: 3, + participantName: 'Amélie Dubois', + startsAt: '2026-09-22T08:30:00.000Z', + location: 'Brussels', + status: 'scheduled', + candidateToken: 'meeting-token', + candidateUrl: '/participant/meeting/meeting-token', + sharedDocument: 'Read the architecture brief.', + attendees: [], + notes: [], + }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/jobs/7') + await user.click(await screen.findByRole('tab', { name: /invitations/i })) + await user.click(screen.getByRole('button', { name: 'Schedule Amélie Dubois' })) + await user.type(screen.getByLabelText('Date and time'), '2026-09-22T10:30') + await user.type(screen.getByLabelText('Location or meeting link'), 'Brussels') + await user.click(await screen.findByLabelText(/Alex Morgan/)) + await user.type(screen.getByLabelText('Shared documentation'), 'Read the architecture brief.') + await user.click(screen.getByRole('button', { name: 'Create interview' })) + + expect(fetch).toHaveBeenCalledWith( + '/api/jobs/7/interviews', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"interviewerIds":[2]'), + }), + ) + expect(await screen.findByText(/Brussels/)).toBeInTheDocument() + expect(screen.getAllByText('Amélie Dubois')).not.toHaveLength(0) + }) + + it('lists users and creates a co-interviewer', async () => { + authenticate() + const user = userEvent.setup() + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + const method = options?.method ?? 'GET' + if (path === '/api/users' && method === 'GET') + return response([ + { id: 1, email: 'admin@example.com', displayName: 'Admin User', role: 'admin' }, + ]) + if (path === '/api/users' && method === 'POST') + return response( + { id: 2, email: 'sam@example.com', displayName: 'Sam Lee', role: 'interviewer' }, + 201, + ) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/users') + expect(await screen.findByText('Admin User')).toBeInTheDocument() + await user.type(screen.getByLabelText('Display name'), 'Sam Lee') + await user.type(screen.getByLabelText('Email address'), 'sam@example.com') + await user.type(screen.getByLabelText('Temporary password'), 'password123') + await user.click(screen.getByRole('button', { name: 'Create co-interviewer' })) + expect(await screen.findByText('Sam Lee')).toBeInTheDocument() + expect(fetch).toHaveBeenCalledWith( + '/api/users', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + email: 'sam@example.com', + password: 'password123', + displayName: 'Sam Lee', + }), + }), + ) + }) + + it('accepts and declines interviews from the inbox', async () => { + authenticate() + const user = userEvent.setup() + const scheduled = { + id: 80, + jobId: 7, + jobTitle: job.title, + participantId: 3, + participantName: 'Amélie Dubois', + startsAt: '2026-09-22T08:30:00Z', + location: 'Brussels', + status: 'pending', + candidateToken: 'meeting-token', + candidateUrl: '/participant/meeting/meeting-token', + sharedDocument: 'Agenda', + attendees: [], + notes: [], + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + if (path === '/api/inbox' && !options?.method) return response([scheduled]) + if (path === '/api/inbox/80') + return response({ ...scheduled, status: JSON.parse(String(options?.body)).status }) + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/inbox') + await user.click(await screen.findByRole('button', { name: 'Accept' })) + await user.click(screen.getByRole('button', { name: 'Decline' })) + expect(fetch).toHaveBeenCalledWith( + '/api/inbox/80', + expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ status: 'accepted' }) }), + ) + expect(screen.getByText('declined')).toBeInTheDocument() + }) + + it('groups calendar interviews by date and links to the session', async () => { + authenticate() + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + if (String(input) === '/api/calendar') + return response([ + { + id: 80, + jobId: 7, + jobTitle: job.title, + participantId: 3, + participantName: 'Amélie Dubois', + startsAt: '2026-09-22T08:30:00Z', + location: 'Brussels', + status: 'scheduled', + candidateToken: 'meeting-token', + candidateUrl: '/participant/meeting/meeting-token', + sharedDocument: 'Agenda', + attendees: [], + notes: [], + }, + ]) + if (!defaultFetch) throw new Error(`Unexpected request: ${String(input)}`) + return defaultFetch(input, options) + }) + renderApp('/calendar') + expect(await screen.findByText('Amélie Dubois')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open session' })).toHaveAttribute( + 'href', + '/scheduled-interviews/80', + ) + expect( + screen.getByRole('heading', { level: 2, name: /September 22, 2026/ }), + ).toBeInTheDocument() + }) + + it('edits shared documentation and displays notes received over authenticated fetch streaming', async () => { + authenticate() + const user = userEvent.setup() + const scheduled = { + id: 80, + jobId: 7, + jobTitle: job.title, + participantId: 3, + participantName: 'Amélie Dubois', + startsAt: '2026-09-22T08:30:00Z', + location: 'Brussels', + status: 'started', + candidateToken: 'meeting-token', + candidateUrl: '/participant/meeting/meeting-token', + sharedDocument: 'Original agenda', + attendees: [ + { userId: 2, displayName: 'Alex Morgan', email: 'alex@example.com', status: 'accepted' }, + ], + notes: [], + } + const liveNote = { + id: 9, + authorUserId: 2, + authorName: 'Alex Morgan', + body: 'Strong system design answer.', + createdAt: '2026-09-22T08:35:00Z', + } + const defaultFetch = vi.mocked(fetch).getMockImplementation() + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL, options?: RequestInit) => { + const path = String(input) + if (path === '/api/scheduled-interviews/80') return response(scheduled) + if (path === '/api/scheduled-interviews/80/notes' && !options?.method) return response([]) + if (path === '/api/scheduled-interviews/80/document') + return response({ + ...scheduled, + sharedDocument: JSON.parse(String(options?.body)).sharedDocument, + }) + if (path === '/api/scheduled-interviews/80/events') { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode(`event: note\ndata: ${JSON.stringify(liveNote)}\n\n`), + ) + controller.close() + }, + }) + return Promise.resolve( + new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }), + ) + } + if (!defaultFetch) throw new Error(`Unexpected request: ${path}`) + return defaultFetch(input, options) + }) + renderApp('/scheduled-interviews/80') + expect(await screen.findByText('Strong system design answer.')).toBeInTheDocument() + const document = screen.getByLabelText('Shared documentation') + await user.clear(document) + await user.type(document, 'Updated candidate exercise') + await user.click(screen.getByRole('button', { name: 'Save shared documentation' })) + expect(fetch).toHaveBeenCalledWith( + '/api/scheduled-interviews/80/document', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ sharedDocument: 'Updated candidate exercise' }), + }), + ) + expect(fetch).toHaveBeenCalledWith( + '/api/scheduled-interviews/80/events', + expect.objectContaining({ + headers: expect.objectContaining({ Accept: 'text/event-stream' }), + }), + ) + }) + + it('shows the public participant meeting without internal notes', async () => { + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + if (String(input) === '/api/participant-meetings/meeting-token') + return response({ + id: 80, + jobTitle: job.title, + participantName: 'Amélie Dubois', + startsAt: '2026-09-22T08:30:00Z', + location: 'Brussels', + status: 'scheduled', + sharedDocument: 'Please prepare the architecture exercise.', + }) + throw new Error(`Unexpected request: ${String(input)}`) + }) + renderApp('/participant/meeting/meeting-token') + expect(await screen.findByRole('heading', { name: job.title })).toBeInTheDocument() + expect(screen.getByText('Please prepare the architecture exercise.')).toBeInTheDocument() + expect(screen.queryByText(/live notes/i)).not.toBeInTheDocument() + expect(screen.queryByText(question.referenceAnswer)).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0172510..d263176 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, ArrowRight, BriefcaseBusiness, + CalendarDays, Check, ChevronRight, CircleHelp, @@ -15,6 +16,7 @@ import { Plus, Search, Send, + Inbox, Sparkles, Trash2, Upload, @@ -42,6 +44,14 @@ import { useParams, } from 'react-router-dom' import { api, ApiError, authStorage } from './api' +import { + CalendarPage, + InboxPage, + ParticipantMeetingPage, + ScheduleInterviewForm, + ScheduledInterviewPage, + UsersPage, +} from './interview-workflow' import type { CandidateMatch, CreateJobInput, @@ -54,6 +64,7 @@ import type { Participant, Question, QuestionType, + ScheduledInterview, } from './types' interface AppData { @@ -276,6 +287,24 @@ function AppLayout({ onLogout }: { onLogout: () => void }) { icon={} close={() => setOpen(false)} /> + } + close={() => setOpen(false)} + /> + } + close={() => setOpen(false)} + /> + } + close={() => setOpen(false)} + />
@@ -301,6 +330,10 @@ function AppLayout({ onLogout }: { onLogout: () => void }) { } /> } /> } /> + } /> + } /> + } /> + } /> } /> @@ -458,6 +491,7 @@ function JobDetailPage() { const [job, setJob] = useState() const [matches, setMatches] = useState([]) const [invitations, setInvitations] = useState([]) + const [scheduledInterviews, setScheduledInterviews] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>( @@ -468,14 +502,16 @@ function JobDetailPage() { setLoading(true) setError('') try { - const [jobValue, candidates, invitationItems] = await Promise.all([ + const [jobValue, candidates, invitationItems, scheduledItems] = await Promise.all([ api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id), + api.jobs.interviews(id), ]) setJob(jobValue) setMatches(candidates) setInvitations(invitationItems) + setScheduledInterviews(scheduledItems) } catch (requestError) { setError(errorMessage(requestError)) } finally { @@ -484,12 +520,18 @@ function JobDetailPage() { } useEffect(() => { let active = true - void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)]) - .then(([jobValue, candidates, invitationItems]) => { + void Promise.all([ + api.jobs.get(id), + api.jobs.candidates(id), + api.jobs.invitations(id), + api.jobs.interviews(id), + ]) + .then(([jobValue, candidates, invitationItems, scheduledItems]) => { if (!active) return setJob(jobValue) setMatches(candidates) setInvitations(invitationItems) + setScheduledInterviews(scheduledItems) }) .catch((requestError: unknown) => { if (active) setError(errorMessage(requestError)) @@ -563,7 +605,14 @@ function JobDetailPage() { job={job} matches={matches} invitations={invitations} + scheduledInterviews={scheduledInterviews} onInvited={(invitation) => setInvitations((current) => [invitation, ...current])} + onInvitationUpdated={(invitation) => + setInvitations((current) => + current.map((item) => (item.id === invitation.id ? invitation : item)), + ) + } + onScheduled={(interview) => setScheduledInterviews((current) => [interview, ...current])} /> )} @@ -752,17 +801,24 @@ function InvitationsView({ job, matches, invitations, + scheduledInterviews, onInvited, + onInvitationUpdated, + onScheduled, }: { job: Job matches: CandidateMatch[] invitations: Invitation[] + scheduledInterviews: ScheduledInterview[] onInvited: (invitation: Invitation) => void + onInvitationUpdated: (invitation: Invitation) => void + onScheduled: (interview: ScheduledInterview) => void }) { const [sendingId, setSendingId] = useState() const [review, setReview] = useState() const [reviewingId, setReviewingId] = useState() const [error, setError] = useState('') + const [scheduling, setScheduling] = useState() const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) @@ -863,7 +919,62 @@ function InvitationsView({ )}
- {review && setReview(undefined)} />} + {review && ( + setReview(undefined)} + onOutcome={(invitation) => { + onInvitationUpdated(invitation) + setReview((current) => current && { ...current, invitation }) + }} + /> + )} + {scheduling && ( + setScheduling(undefined)} + onCreated={(interview) => { + onScheduled(interview) + setScheduling(undefined) + }} + /> + )} +
+ {invitations + .filter((invitation) => invitation.outcome === 'passed') + .filter( + (invitation) => + !scheduledInterviews.some( + (interview) => interview.participantId === invitation.participantId, + ), + ) + .map((invitation) => ( + + ))} + {scheduledInterviews.map((interview) => ( + + + {interview.participantName} + + {new Date(interview.startsAt).toLocaleString()} · {interview.location} + + + + + ))} +
) } @@ -871,10 +982,32 @@ function InvitationsView({ function InvitationAnswerReview({ interview, onClose, + onOutcome, }: { interview: Interview onClose: () => void + onOutcome: (invitation: Invitation) => void }) { + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + async function decide(outcome: 'passed' | 'failed') { + setSaving(true) + setError('') + try { + onOutcome( + await api.jobs.setInvitationOutcome( + interview.invitation.jobId, + interview.invitation.id, + outcome, + ), + ) + } catch (requestError) { + setError(errorMessage(requestError)) + } finally { + setSaving(false) + } + } return (
@@ -883,6 +1016,32 @@ function InvitationAnswerReview({

{interview.invitation.participantName}

{interview.invitation.participantEmail}

+ {interview.invitation.status === 'completed' && ( +
+
+ Assessment decision +

Record whether this candidate should proceed to a scheduled interview.

+
+ + + {interview.invitation.outcome && } +
+ )} + {error && }
+
+ +
+ + ) +} + +export function ScheduleInterviewForm({ + jobId, + invitation, + onCreated, + onCancel, +}: { + jobId: number + invitation: Invitation + onCreated: (interview: ScheduledInterview) => void + onCancel: () => void +}) { + const [users, setUsers] = useState([]) + const [selected, setSelected] = useState([]) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + + useEffect(() => { + void api.users + .list() + .then(setUsers) + .catch((value) => setError(message(value))) + }, []) + + async function submit(event: FormEvent) { + event.preventDefault() + const values = new FormData(event.currentTarget) + setSaving(true) + setError('') + try { + const startsAt = new Date(String(values.get('startsAt'))).toISOString() + onCreated( + await api.jobs.scheduleInterview(jobId, { + invitationId: invitation.id, + startsAt, + location: String(values.get('location')), + interviewerIds: selected, + sharedDocument: String(values.get('sharedDocument')), + }), + ) + } catch (value) { + setError(message(value)) + setSaving(false) + } + } + + return ( +
+
+
+ Passed assessment +

Schedule interview with {invitation.participantName}

+
+ +
+
+
+ + +
+
+ Co-interviewers + {users.map((user) => ( + + ))} +
+