feat: add collaborative interview scheduling
Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ. Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
fd06fbbb5d
commit
75e3c73a61
35 changed files with 3621 additions and 58 deletions
18
.github/copilot-instructions.md
vendored
18
.github/copilot-instructions.md
vendored
|
|
@ -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`.
|
||||
|
|
|
|||
3
Makefile
3
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
|
||||
|
|
|
|||
27
README.md
27
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
212
backend/internal/eventbus/amqp.go
Normal file
212
backend/internal/eventbus/amqp.go
Normal file
|
|
@ -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
|
||||
}
|
||||
86
backend/internal/eventbus/eventbus.go
Normal file
86
backend/internal/eventbus/eventbus.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
261
backend/internal/service/interviews.go
Normal file
261
backend/internal/service/interviews.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
backend/migrations/00006_next_stage_interviews.sql
Normal file
60
backend/migrations/00006_next_stage_interviews.sql
Normal file
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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={<CircleHelp size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<NavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={<Inbox size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<NavItem
|
||||
to="/calendar"
|
||||
label="Calendar"
|
||||
icon={<CalendarDays size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<NavItem
|
||||
to="/users"
|
||||
label="Users"
|
||||
icon={<UserRound size={20} />}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
</nav>
|
||||
<div className="sidebar-bottom">
|
||||
<div className="user-chip">
|
||||
|
|
@ -301,6 +330,10 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
|
|||
<Route path="/participants" element={<ParticipantsPage />} />
|
||||
<Route path="/participants/new" element={<CreateParticipantPage />} />
|
||||
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/inbox" element={<InboxPage />} />
|
||||
<Route path="/calendar" element={<CalendarPage />} />
|
||||
<Route path="/scheduled-interviews/:id" element={<ScheduledInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
|
@ -458,6 +491,7 @@ function JobDetailPage() {
|
|||
const [job, setJob] = useState<Job>()
|
||||
const [matches, setMatches] = useState<CandidateMatch[]>([])
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([])
|
||||
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<number>()
|
||||
const [review, setReview] = useState<Interview>()
|
||||
const [reviewingId, setReviewingId] = useState<number>()
|
||||
const [error, setError] = useState('')
|
||||
const [scheduling, setScheduling] = useState<Invitation>()
|
||||
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({
|
|||
<EmptyState title="No invitations" description="Invite a candidate to this interview." />
|
||||
)}
|
||||
</div>
|
||||
{review && <InvitationAnswerReview interview={review} onClose={() => setReview(undefined)} />}
|
||||
{review && (
|
||||
<InvitationAnswerReview
|
||||
interview={review}
|
||||
onClose={() => setReview(undefined)}
|
||||
onOutcome={(invitation) => {
|
||||
onInvitationUpdated(invitation)
|
||||
setReview((current) => current && { ...current, invitation })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{scheduling && (
|
||||
<ScheduleInterviewForm
|
||||
jobId={job.id}
|
||||
invitation={scheduling}
|
||||
onCancel={() => setScheduling(undefined)}
|
||||
onCreated={(interview) => {
|
||||
onScheduled(interview)
|
||||
setScheduling(undefined)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="scheduled-list">
|
||||
{invitations
|
||||
.filter((invitation) => invitation.outcome === 'passed')
|
||||
.filter(
|
||||
(invitation) =>
|
||||
!scheduledInterviews.some(
|
||||
(interview) => interview.participantId === invitation.participantId,
|
||||
),
|
||||
)
|
||||
.map((invitation) => (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
key={invitation.id}
|
||||
onClick={() => setScheduling(invitation)}
|
||||
>
|
||||
<CalendarDays size={17} /> Schedule {invitation.participantName}
|
||||
</button>
|
||||
))}
|
||||
{scheduledInterviews.map((interview) => (
|
||||
<Link
|
||||
className="scheduled-summary"
|
||||
to={`/scheduled-interviews/${interview.id}`}
|
||||
key={interview.id}
|
||||
>
|
||||
<span>
|
||||
<strong>{interview.participantName}</strong>
|
||||
<small>
|
||||
{new Date(interview.startsAt).toLocaleString()} · {interview.location}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge value={interview.status} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className="answer-review" aria-labelledby="answer-review-title">
|
||||
<div className="section-heading">
|
||||
|
|
@ -883,6 +1016,32 @@ function InvitationAnswerReview({
|
|||
<h2 id="answer-review-title">{interview.invitation.participantName}</h2>
|
||||
<p>{interview.invitation.participantEmail}</p>
|
||||
</div>
|
||||
{interview.invitation.status === 'completed' && (
|
||||
<div className="outcome-actions" aria-label="Candidate outcome">
|
||||
<div>
|
||||
<strong>Assessment decision</strong>
|
||||
<p>Record whether this candidate should proceed to a scheduled interview.</p>
|
||||
</div>
|
||||
<button
|
||||
className="button pass"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void decide('passed')}
|
||||
>
|
||||
<Check size={18} /> Pass candidate
|
||||
</button>
|
||||
<button
|
||||
className="button fail"
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void decide('failed')}
|
||||
>
|
||||
<X size={18} /> Fail candidate
|
||||
</button>
|
||||
{interview.invitation.outcome && <StatusBadge value={interview.invitation.outcome} />}
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorAlert message={error} />}
|
||||
<div className="review-heading-actions">
|
||||
<StatusBadge value={interview.invitation.status} />
|
||||
<button className="button ghost" type="button" onClick={onClose}>
|
||||
|
|
@ -2247,6 +2406,7 @@ export default function App() {
|
|||
if (location.pathname.startsWith('/participant/')) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
|
||||
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ describe('API client', () => {
|
|||
it('sends the persisted bearer token on protected requests', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
|
@ -27,7 +27,7 @@ describe('API client', () => {
|
|||
it('uses the backend participant multipart field names', async () => {
|
||||
authStorage.save({
|
||||
token: 'signed-jwt',
|
||||
user: { id: 1, email: 'admin@example.com', role: 'admin' },
|
||||
user: { id: 1, email: 'admin@example.com', displayName: 'Admin', role: 'admin' },
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ import type {
|
|||
CreateJobInput,
|
||||
CreateParticipantInput,
|
||||
CreateQuestionInput,
|
||||
CreateScheduledInterviewInput,
|
||||
Interview,
|
||||
Invitation,
|
||||
Job,
|
||||
JobStatus,
|
||||
Participant,
|
||||
ParticipantMeeting,
|
||||
Question,
|
||||
ScheduledInterview,
|
||||
User,
|
||||
InterviewNote,
|
||||
} from './types'
|
||||
|
||||
const TOKEN_KEY = 'go-guess-token'
|
||||
|
|
@ -131,6 +136,17 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ participantId }),
|
||||
}),
|
||||
setInvitationOutcome: (jobId: number, invitationId: number, outcome: Invitation['outcome']) =>
|
||||
request<Invitation>(`/jobs/${jobId}/invitations/${invitationId}/outcome`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ outcome }),
|
||||
}),
|
||||
interviews: (jobId: number) => request<ScheduledInterview[]>(`/jobs/${jobId}/interviews`),
|
||||
scheduleInterview: (jobId: number, input: CreateScheduledInterviewInput) =>
|
||||
request<ScheduledInterview>(`/jobs/${jobId}/interviews`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
},
|
||||
questions: {
|
||||
list: (search = '') =>
|
||||
|
|
@ -178,4 +194,76 @@ export const api = {
|
|||
return request<Participant>('/participants', { method: 'POST', body: form })
|
||||
},
|
||||
},
|
||||
users: {
|
||||
list: () => request<User[]>('/users'),
|
||||
create: (input: { email: string; password: string; displayName: string }) =>
|
||||
request<User>('/users', { method: 'POST', body: JSON.stringify(input) }),
|
||||
},
|
||||
inbox: {
|
||||
list: () => request<ScheduledInterview[]>('/inbox'),
|
||||
respond: (interviewId: number, status: 'accepted' | 'declined') =>
|
||||
request<ScheduledInterview>(`/inbox/${interviewId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
},
|
||||
calendar: () => request<ScheduledInterview[]>('/calendar'),
|
||||
scheduledInterviews: {
|
||||
get: (id: number) => request<ScheduledInterview>(`/scheduled-interviews/${id}`),
|
||||
setStatus: (id: number, status: string) =>
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
updateDocument: (id: number, sharedDocument: string) =>
|
||||
request<ScheduledInterview>(`/scheduled-interviews/${id}/document`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sharedDocument }),
|
||||
}),
|
||||
notes: (id: number) => request<InterviewNote[]>(`/scheduled-interviews/${id}/notes`),
|
||||
addNote: (id: number, body: string) =>
|
||||
request<InterviewNote>(`/scheduled-interviews/${id}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
subscribe: async (id: number, onNote: (note: InterviewNote) => void, signal: AbortSignal) => {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api/scheduled-interviews/${id}/events`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const message = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = message
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (data) {
|
||||
const event = JSON.parse(data) as InterviewNote | { note: InterviewNote }
|
||||
onNote('note' in event ? event.note : event)
|
||||
}
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
participantMeetings: {
|
||||
get: (token: string) =>
|
||||
request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
660
frontend/src/interview-workflow.tsx
Normal file
660
frontend/src/interview-workflow.tsx
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import { CalendarDays, Check, MapPin, Plus, UsersRound, X } from 'lucide-react'
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { api, ApiError } from './api'
|
||||
import type { Invitation, ParticipantMeeting, ScheduledInterview, User } from './types'
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof ApiError || error instanceof Error
|
||||
? error.message
|
||||
: 'Something went wrong'
|
||||
}
|
||||
|
||||
function ErrorAlert({ error }: { error: string }) {
|
||||
return (
|
||||
<div className="error-alert" role="alert">
|
||||
<strong>Request failed</strong>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<div className="empty-state" role="status">
|
||||
<span className="loading-spinner" />
|
||||
<p>Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DateAndPlace({ interview }: { interview: ScheduledInterview | ParticipantMeeting }) {
|
||||
return (
|
||||
<div className="meeting-facts">
|
||||
<span>
|
||||
<CalendarDays size={18} />
|
||||
{new Date(interview.startsAt).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
<MapPin size={18} />
|
||||
{interview.location}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void api.users
|
||||
.list()
|
||||
.then(setUsers)
|
||||
.catch((value) => setError(message(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function create(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
const form = event.currentTarget
|
||||
const values = new FormData(form)
|
||||
try {
|
||||
const created = await api.users.create({
|
||||
email: String(values.get('email')),
|
||||
password: String(values.get('password')),
|
||||
displayName: String(values.get('displayName')),
|
||||
})
|
||||
setUsers((current) => [...current, created])
|
||||
form.reset()
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Team access</span>
|
||||
<h1>Users</h1>
|
||||
<p>Create co-interviewers and manage the people who collaborate on interviews.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
<div className="library-grid">
|
||||
<section className="card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Interview team</h2>
|
||||
<p>{users.length} workspace users</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<div className="user-list">
|
||||
{users.map((user) => (
|
||||
<article className="user-row" key={user.id}>
|
||||
<span className="avatar">{user.displayName.slice(0, 2).toUpperCase()}</span>
|
||||
<div>
|
||||
<strong>{user.displayName}</strong>
|
||||
<small>{user.email}</small>
|
||||
</div>
|
||||
<span className={`status status-${user.role}`}>{user.role}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<form className="card form-card library-form" onSubmit={create}>
|
||||
<div className="form-section">
|
||||
<h2>Create co-interviewer</h2>
|
||||
<label className="field">
|
||||
<span className="field-label">Display name</span>
|
||||
<input name="displayName" required autoComplete="name" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Email address</span>
|
||||
<input name="email" type="email" required autoComplete="email" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Temporary password</span>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<button className="button primary wide" type="submit" disabled={saving}>
|
||||
<Plus size={18} /> {saving ? 'Creating…' : 'Create co-interviewer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleInterviewForm({
|
||||
jobId,
|
||||
invitation,
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
jobId: number
|
||||
invitation: Invitation
|
||||
onCreated: (interview: ScheduledInterview) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form
|
||||
className="schedule-form"
|
||||
onSubmit={submit}
|
||||
aria-label={`Schedule ${invitation.participantName}`}
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Passed assessment</span>
|
||||
<h2>Schedule interview with {invitation.participantName}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="icon-button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label="Close scheduling form"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Date and time</span>
|
||||
<input name="startsAt" type="datetime-local" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Location or meeting link</span>
|
||||
<input name="location" required />
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className="interviewer-picker">
|
||||
<legend className="field-label">Co-interviewers</legend>
|
||||
{users.map((user) => (
|
||||
<label key={user.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(user.id)}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
event.target.checked
|
||||
? [...current, user.id]
|
||||
: current.filter((id) => id !== user.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{user.displayName}</strong>
|
||||
<small>{user.email}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<label className="field shared-document-field">
|
||||
<span className="field-label">Shared documentation</span>
|
||||
<textarea
|
||||
name="sharedDocument"
|
||||
rows={10}
|
||||
required
|
||||
placeholder="Add the agenda, exercise, links, and information shared with the candidate…"
|
||||
/>
|
||||
</label>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
<div className="question-form-actions">
|
||||
<button className="button ghost" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Scheduling…' : 'Create interview'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function InterviewCard({
|
||||
interview,
|
||||
children,
|
||||
}: {
|
||||
interview: ScheduledInterview
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article className="meeting-card">
|
||||
<div>
|
||||
<span className={`status status-${interview.status}`}>{interview.status}</span>
|
||||
<h2>{interview.participantName}</h2>
|
||||
<p>{interview.jobTitle}</p>
|
||||
</div>
|
||||
<DateAndPlace interview={interview} />
|
||||
{children}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function InboxPage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
void api.inbox
|
||||
.list()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(message(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function respond(id: number, status: 'accepted' | 'declined') {
|
||||
setError('')
|
||||
try {
|
||||
const updated = await api.inbox.respond(id, status)
|
||||
setItems((current) => current.map((item) => (item.id === id ? updated : item)))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Invitations</span>
|
||||
<h1>Inbox</h1>
|
||||
<p>Respond to interview invitations assigned to you.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<section className="meeting-list">
|
||||
{items.map((item) => (
|
||||
<InterviewCard interview={item} key={item.id}>
|
||||
<div className="meeting-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void respond(item.id, 'accepted')}
|
||||
>
|
||||
<Check size={17} /> Accept
|
||||
</button>
|
||||
<button
|
||||
className="button secondary danger"
|
||||
type="button"
|
||||
onClick={() => void respond(item.id, 'declined')}
|
||||
>
|
||||
<X size={17} /> Decline
|
||||
</button>
|
||||
</div>
|
||||
</InterviewCard>
|
||||
))}
|
||||
{!items.length && (
|
||||
<div className="card empty-state">
|
||||
<h3>Inbox zero</h3>
|
||||
<p>You have no interview invitations.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CalendarPage() {
|
||||
const [items, setItems] = useState<ScheduledInterview[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
void api
|
||||
.calendar()
|
||||
.then(setItems)
|
||||
.catch((value) => setError(message(value)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
const groups = useMemo(() => {
|
||||
return [...items]
|
||||
.sort((a, b) => a.startsAt.localeCompare(b.startsAt))
|
||||
.reduce<Record<string, ScheduledInterview[]>>((result, item) => {
|
||||
const key = new Date(item.startsAt).toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
;(result[key] ??= []).push(item)
|
||||
return result
|
||||
}, {})
|
||||
}, [items])
|
||||
return (
|
||||
<>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Schedule</span>
|
||||
<h1>Calendar</h1>
|
||||
<p>Your accepted and organized interviews, grouped by date.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
{loading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
Object.entries(groups).map(([date, interviews]) => (
|
||||
<section className="calendar-day" key={date}>
|
||||
<h2>{date}</h2>
|
||||
<div className="meeting-list">
|
||||
{interviews.map((item) => (
|
||||
<InterviewCard interview={item} key={item.id}>
|
||||
<Link className="button secondary" to={`/scheduled-interviews/${item.id}`}>
|
||||
Open session
|
||||
</Link>
|
||||
</InterviewCard>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduledInterviewPage() {
|
||||
const id = Number(useParams().id)
|
||||
const [interview, setInterview] = useState<ScheduledInterview>()
|
||||
const [document, setDocument] = useState('')
|
||||
const [notes, setNotes] = useState<ScheduledInterview['notes']>([])
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void Promise.all([api.scheduledInterviews.get(id), api.scheduledInterviews.notes(id)])
|
||||
.then(([item, loadedNotes]) => {
|
||||
if (!active) return
|
||||
setInterview(item)
|
||||
setDocument(item.sharedDocument)
|
||||
setNotes(loadedNotes)
|
||||
})
|
||||
.catch((value) => active && setError(message(value)))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (interview?.status !== 'started') return
|
||||
const controller = new AbortController()
|
||||
void api.scheduledInterviews
|
||||
.subscribe(
|
||||
id,
|
||||
(note) => {
|
||||
setNotes((current) =>
|
||||
current.some((item) => item.id === note.id) ? current : [...current, note],
|
||||
)
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.catch((value) => {
|
||||
if (!controller.signal.aborted) setError(message(value))
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [id, interview?.status])
|
||||
|
||||
async function setStatus(status: string) {
|
||||
setError('')
|
||||
try {
|
||||
setInterview(await api.scheduledInterviews.setStatus(id, status))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDocument(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
setInterview(await api.scheduledInterviews.updateDocument(id, document))
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addNote(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const body = String(new FormData(form).get('body')).trim()
|
||||
if (!body) return
|
||||
try {
|
||||
const note = await api.scheduledInterviews.addNote(id, body)
|
||||
setNotes((current) =>
|
||||
current.some((item) => item.id === note.id) ? current : [...current, note],
|
||||
)
|
||||
form.reset()
|
||||
} catch (value) {
|
||||
setError(message(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (!interview && !error) return <Loading />
|
||||
if (!interview) return <ErrorAlert error={error} />
|
||||
return (
|
||||
<>
|
||||
<Link className="back-link" to="/calendar">
|
||||
← Calendar
|
||||
</Link>
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewer session</span>
|
||||
<h1>{interview.participantName}</h1>
|
||||
<p>{interview.jobTitle}</p>
|
||||
</div>
|
||||
<span className={`status status-${interview.status}`}>{interview.status}</span>
|
||||
</header>
|
||||
{error && <ErrorAlert error={error} />}
|
||||
<DateAndPlace interview={interview} />
|
||||
<div className="session-actions">
|
||||
{interview.status !== 'started' && interview.status !== 'completed' && (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void setStatus('started')}
|
||||
>
|
||||
Start interview
|
||||
</button>
|
||||
)}
|
||||
{interview.status === 'started' && (
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
onClick={() => void setStatus('completed')}
|
||||
>
|
||||
Complete interview
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-grid">
|
||||
<form className="card shared-document" onSubmit={saveDocument}>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Visible to candidate</span>
|
||||
<h2>Shared documentation</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-section">
|
||||
<textarea
|
||||
aria-label="Shared documentation"
|
||||
rows={18}
|
||||
value={document}
|
||||
onChange={(event) => setDocument(event.target.value)}
|
||||
/>
|
||||
<button className="button primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save shared documentation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<section className="card notes-panel">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Interviewers only</span>
|
||||
<h2>Live notes</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="notes-list" aria-live="polite">
|
||||
{notes.map((note) => (
|
||||
<article className="note" key={note.id}>
|
||||
<strong>{note.authorName}</strong>
|
||||
<time>{new Date(note.createdAt).toLocaleTimeString()}</time>
|
||||
<p>{note.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{interview.status === 'started' ? (
|
||||
<form className="note-form" onSubmit={addNote}>
|
||||
<label className="sr-only" htmlFor="interview-note">
|
||||
Add a note
|
||||
</label>
|
||||
<textarea
|
||||
id="interview-note"
|
||||
name="body"
|
||||
rows={3}
|
||||
required
|
||||
placeholder="Add a private interviewer note…"
|
||||
/>
|
||||
<button className="button primary" type="submit">
|
||||
Post note
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="panel-padding muted">
|
||||
Notes can be added while the interview is started.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<section className="card attendee-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Attendees</h2>
|
||||
<p>Interview team and response status.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-list">
|
||||
{interview.attendees.map((attendee) => (
|
||||
<article className="user-row" key={attendee.userId}>
|
||||
<UsersRound />
|
||||
<div>
|
||||
<strong>{attendee.displayName}</strong>
|
||||
<small>{attendee.email}</small>
|
||||
</div>
|
||||
<span className={`status status-${attendee.status}`}>{attendee.status}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParticipantMeetingPage() {
|
||||
const token = useParams().token ?? ''
|
||||
const [meeting, setMeeting] = useState<ParticipantMeeting>()
|
||||
const [error, setError] = useState('')
|
||||
useEffect(() => {
|
||||
void api.participantMeetings
|
||||
.get(token)
|
||||
.then(setMeeting)
|
||||
.catch((value) => setError(message(value)))
|
||||
}, [token])
|
||||
return (
|
||||
<main className="public-interview">
|
||||
<header>
|
||||
<Link className="logo" to="/" aria-label="Go Guess home">
|
||||
<span className="logo-mark">G</span>
|
||||
<span>
|
||||
<strong>GO GUESS</strong>
|
||||
<small>Talent workspace</small>
|
||||
</span>
|
||||
</Link>
|
||||
</header>
|
||||
{!meeting && !error && (
|
||||
<section className="interview-card">
|
||||
<Loading />
|
||||
</section>
|
||||
)}
|
||||
{error && (
|
||||
<section className="interview-card">
|
||||
<ErrorAlert error={error} />
|
||||
</section>
|
||||
)}
|
||||
{meeting && (
|
||||
<section className="interview-card meeting-public-card">
|
||||
<span className="eyebrow">Your scheduled interview</span>
|
||||
<h1>{meeting.jobTitle}</h1>
|
||||
<p>Welcome, {meeting.participantName}.</p>
|
||||
<span className={`status status-${meeting.status}`}>{meeting.status}</span>
|
||||
<DateAndPlace interview={meeting} />
|
||||
<section className="public-document" aria-labelledby="shared-document-title">
|
||||
<h2 id="shared-document-title">Shared documentation</h2>
|
||||
<div>{meeting.sharedDocument || 'No shared documentation has been added yet.'}</div>
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -767,6 +767,33 @@ td small {
|
|||
.button.danger {
|
||||
color: #934141;
|
||||
}
|
||||
.button.pass {
|
||||
color: #0b573c;
|
||||
background: #d9ef4d;
|
||||
border-color: #bed338;
|
||||
}
|
||||
.button.fail {
|
||||
color: white;
|
||||
background: #a43d3d;
|
||||
}
|
||||
.status-passed,
|
||||
.status-accepted,
|
||||
.status-started,
|
||||
.status-completed {
|
||||
color: #13704d;
|
||||
background: #e3f5ed;
|
||||
}
|
||||
.status-failed,
|
||||
.status-declined,
|
||||
.status-cancelled {
|
||||
color: #934141;
|
||||
background: #f4e7e7;
|
||||
}
|
||||
.status-pending,
|
||||
.status-scheduled {
|
||||
color: #925a09;
|
||||
background: #fff1dc;
|
||||
}
|
||||
.panel-padding,
|
||||
.invite-form {
|
||||
padding: 1.2rem 1.5rem;
|
||||
|
|
@ -909,6 +936,215 @@ td small {
|
|||
.answer-review {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.outcome-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 1.2rem 1.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #f6f9df;
|
||||
}
|
||||
.outcome-actions > div {
|
||||
margin-right: auto;
|
||||
}
|
||||
.outcome-actions p {
|
||||
color: var(--muted);
|
||||
margin: 0.2rem 0 0;
|
||||
}
|
||||
.schedule-form {
|
||||
margin: 1.5rem;
|
||||
border: 2px solid var(--cyan);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
.scheduled-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.scheduled-list > .button {
|
||||
justify-self: start;
|
||||
}
|
||||
.scheduled-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.scheduled-summary > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scheduled-summary small {
|
||||
color: var(--muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.interviewer-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.interviewer-picker legend {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.interviewer-picker label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
}
|
||||
.interviewer-picker input {
|
||||
width: auto;
|
||||
}
|
||||
.interviewer-picker span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.interviewer-picker small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.shared-document-field {
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--cyan);
|
||||
border-radius: 12px;
|
||||
background: #f2fbfc;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.user-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.user-row > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.user-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.meeting-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.meeting-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) minmax(260px, 1fr) auto;
|
||||
gap: 1.2rem;
|
||||
align-items: center;
|
||||
padding: 1.3rem 1.5rem;
|
||||
border: 1px solid #e1e9eb;
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
box-shadow: 0 6px 24px rgba(14, 42, 56, 0.04);
|
||||
}
|
||||
.meeting-card h2 {
|
||||
margin: 0.55rem 0 0.2rem;
|
||||
}
|
||||
.meeting-card p {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.meeting-facts {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
color: #526872;
|
||||
}
|
||||
.meeting-facts span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.meeting-actions,
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.calendar-day {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.calendar-day > h2 {
|
||||
color: var(--navy);
|
||||
border-bottom: 2px solid var(--cyan);
|
||||
padding-bottom: 0.65rem;
|
||||
}
|
||||
.session-actions {
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
.session-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 1.2rem;
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
.shared-document {
|
||||
border: 2px solid var(--cyan);
|
||||
}
|
||||
.shared-document textarea {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.notes-list {
|
||||
max-height: 440px;
|
||||
overflow-y: auto;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.note {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.note time {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.6rem;
|
||||
}
|
||||
.note p {
|
||||
white-space: pre-wrap;
|
||||
margin: 0.45rem 0 0;
|
||||
}
|
||||
.note-form {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.note-form .button {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.attendee-section {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
.meeting-public-card {
|
||||
padding: clamp(2rem, 6vw, 4rem);
|
||||
}
|
||||
.meeting-public-card > p {
|
||||
color: var(--muted);
|
||||
}
|
||||
.meeting-public-card .meeting-facts {
|
||||
margin: 1.6rem 0;
|
||||
}
|
||||
.public-document {
|
||||
margin-top: 2rem;
|
||||
padding: clamp(1.2rem, 4vw, 2rem);
|
||||
border: 3px solid var(--lime);
|
||||
border-radius: 14px;
|
||||
background: #f9fce8;
|
||||
}
|
||||
.public-document div {
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.review-heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1610,7 +1846,8 @@ fieldset {
|
|||
|
||||
@media (max-width: 1100px) {
|
||||
.split-panels,
|
||||
.library-grid {
|
||||
.library-grid,
|
||||
.session-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.library-form {
|
||||
|
|
@ -1797,6 +2034,15 @@ fieldset {
|
|||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.outcome-actions,
|
||||
.meeting-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
}
|
||||
.interviewer-picker {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export type QuestionType = 'open' | 'multiple_choice' | 'radio' | 'code_review'
|
|||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
displayName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
|
|
@ -67,12 +68,62 @@ export interface Invitation {
|
|||
participantEmail: string
|
||||
token: string
|
||||
status: string
|
||||
outcome: 'pending' | 'passed' | 'failed'
|
||||
participantUrl: string
|
||||
acceptedAt: string | null
|
||||
completedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface InterviewAttendee {
|
||||
userId: number
|
||||
displayName: string
|
||||
email: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface InterviewNote {
|
||||
id: number
|
||||
authorUserId: number
|
||||
authorName: string
|
||||
body: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ScheduledInterview {
|
||||
id: number
|
||||
jobId: number
|
||||
jobTitle: string
|
||||
participantId: number
|
||||
participantName: string
|
||||
startsAt: string
|
||||
location: string
|
||||
status: string
|
||||
candidateToken: string
|
||||
candidateUrl: string
|
||||
sharedDocument: string
|
||||
attendees: InterviewAttendee[]
|
||||
notes: InterviewNote[]
|
||||
}
|
||||
|
||||
export interface CreateScheduledInterviewInput {
|
||||
invitationId: number
|
||||
startsAt: string
|
||||
location: string
|
||||
interviewerIds: number[]
|
||||
sharedDocument: string
|
||||
}
|
||||
|
||||
export interface ParticipantMeeting {
|
||||
id: number
|
||||
jobTitle: string
|
||||
participantName: string
|
||||
startsAt: string
|
||||
location: string
|
||||
status: string
|
||||
sharedDocument: string
|
||||
}
|
||||
|
||||
export interface Interview {
|
||||
invitation: Invitation
|
||||
job: Pick<
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue