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:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -5,9 +5,9 @@
Run repository-wide commands from the root: Run repository-wide commands from the root:
```bash ```bash
make dev # run PostgreSQL, API, and frontend with development fixtures make dev # run PostgreSQL, RabbitMQ, API, and frontend with development fixtures
make test # complete suite, including disposable PostgreSQL integration tests make test # complete suite, including disposable infrastructure integration tests
make test-integration # Go tests; starts its own PostgreSQL Testcontainer make test-integration # Go tests; starts PostgreSQL and RabbitMQ Testcontainers
make test-env # isolated seeded UI stack at http://localhost:15173 make test-env # isolated seeded UI stack at http://localhost:15173
make test-env-down # remove the test stack and its PostgreSQL volume make test-env-down # remove the test stack and its PostgreSQL volume
make lint # go vet and ESLint 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 `internal/database/{repository,mapper,entity}`. Never expose GORM entities from
handlers. handlers.
- `backend/cmd/seed` applies embedded environment-aware fixtures. - `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 - `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 boundary, and `src/types.ts` contains client domain types. Vite proxies `/api` to
the Go service in development. the Go service in development.
@ -63,6 +67,14 @@ questions are acceptance-gated and answers are saved before final submission;
interviewer review uses interviewer review uses
`GET /api/jobs/{id}/invitations/{invitationId}`. `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 ## Repository conventions
- Prefix every Go interface name with `I`. - Prefix every Go interface name with `I`.

View file

@ -46,7 +46,8 @@ test-integration:
test-env: test-env:
COMPOSE_PROJECT_NAME=go-guess-test APP_ENV=test DB_PORT=15432 API_PORT=18080 \ 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: test-env-down:
COMPOSE_PROJECT_NAME=go-guess-test docker compose down -v --remove-orphans COMPOSE_PROJECT_NAME=go-guess-test docker compose down -v --remove-orphans

View file

@ -3,13 +3,14 @@
Recruitment workspace for interviewers to define job profiles and a reusable Recruitment workspace for interviewers to define job profiles and a reusable
question library, manage participants, extract known traits from uploaded CVs, 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 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 ## Stack
- Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs - Go 1.27.1 HTTP API using Chi, GORM, PostgreSQL, Goose, bcrypt, and signed JWTs
- React 19.3, TypeScript, and Vite - React 19.3, TypeScript, and Vite
- PostgreSQL 18 - PostgreSQL 18
- RabbitMQ 4 for live interview-note fanout
## Run locally ## Run locally
@ -40,8 +41,8 @@ outside local development.
## Quality commands ## Quality commands
```bash ```bash
make test # full suite, including disposable PostgreSQL integration tests make test # full suite, including disposable infrastructure integration tests
make test-integration # Go integration suite with its own PostgreSQL container make test-integration # Go integration suite with PostgreSQL and RabbitMQ containers
make lint make lint
make build 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 Docker must be available for `make test`. The integration suite starts PostgreSQL
18 in a disposable Testcontainer, applies every Goose migration, loads deterministic 18 and RabbitMQ 4 in disposable Testcontainers, applies every Goose migration,
test fixtures, exercises login and the complete interview workflow, and removes the loads deterministic test fixtures, exercises login and the complete assessment and
container afterward. interview workflow, verifies live note fanout, and removes both containers
afterward.
Start an isolated application stack populated with test data for manual UI testing: 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 saved answer from the job's invitation list. Vite proxies `/api` to the Go service
during local development. 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 Goose migrations in `backend/migrations` are schema-only and run in every
environment. Embedded SQL under `backend/internal/database/seed` separates shared environment. Embedded SQL under `backend/internal/database/seed` separates shared
reference traits from idempotent development and test fixtures. Docker Compose reference traits from idempotent development and test fixtures. Docker Compose

View file

@ -13,6 +13,7 @@ import (
"github.com/danyel/go-guess/backend/internal/config" "github.com/danyel/go-guess/backend/internal/config"
"github.com/danyel/go-guess/backend/internal/database" "github.com/danyel/go-guess/backend/internal/database"
"github.com/danyel/go-guess/backend/internal/database/repository" "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/security"
"github.com/danyel/go-guess/backend/internal/service" "github.com/danyel/go-guess/backend/internal/service"
"github.com/danyel/go-guess/backend/internal/web/handler" "github.com/danyel/go-guess/backend/internal/web/handler"
@ -38,8 +39,15 @@ func main() {
defer sqlDB.Close() defer sqlDB.Close()
store := repository.New(db) 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) tokens := security.NewTokenManager(cfg.JWTSecret, cfg.TokenTTL)
invitations := service.NewInvitationService(store, cfg.FrontendURL) invitations := service.NewInvitationService(store, cfg.FrontendURL)
interviews := service.NewScheduledInterviewService(store, bus)
api := router.New( api := router.New(
handler.New( handler.New(
service.NewAuthService(store, tokens), service.NewAuthService(store, tokens),
@ -47,6 +55,8 @@ func main() {
service.NewQuestionService(store), service.NewQuestionService(store),
service.NewParticipantService(store), service.NewParticipantService(store),
invitations, invitations,
service.NewUserService(store),
interviews,
cfg.MaxUploadMB, cfg.MaxUploadMB,
cfg.FrontendURL, cfg.FrontendURL,
), ),
@ -55,7 +65,7 @@ func main() {
) )
server := &http.Server{ server := &http.Server{
Addr: cfg.Address, Handler: api, ReadHeaderTimeout: 5 * time.Second, 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() { go func() {

View file

@ -8,6 +8,10 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 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 golang.org/x/crypto v0.54.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0 gorm.io/gorm v1.31.0
@ -55,13 +59,10 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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/sethvargo/go-retry v0.3.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/stretchr/testify v1.11.1 // 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/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect

View file

@ -1,5 +1,7 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 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 h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 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/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 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= 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/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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 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 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 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= 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/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 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 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 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4= 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 h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 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= 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/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 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 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/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 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= 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 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 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= 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 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= 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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= 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 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 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= 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 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= 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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= 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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/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 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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=

View file

@ -14,6 +14,7 @@ type Config struct {
TokenTTL time.Duration TokenTTL time.Duration
MaxUploadMB int64 MaxUploadMB int64
FrontendURL string FrontendURL string
RabbitMQURL string
} }
func Load() (Config, error) { func Load() (Config, error) {
@ -32,6 +33,7 @@ func Load() (Config, error) {
TokenTTL: ttl, TokenTTL: ttl,
MaxUploadMB: maxUploadMB, MaxUploadMB: maxUploadMB,
FrontendURL: env("FRONTEND_URL", "http://localhost:5173"), FrontendURL: env("FRONTEND_URL", "http://localhost:5173"),
RabbitMQURL: env("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
} }
if len(cfg.JWTSecret) < 32 { if len(cfg.JWTSecret) < 32 {
return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters") return Config{}, fmt.Errorf("JWT_SECRET must contain at least 32 characters")

View file

@ -6,6 +6,7 @@ type User struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
Email string `gorm:"uniqueIndex;size:320;not null"` Email string `gorm:"uniqueIndex;size:320;not null"`
PasswordHash string `gorm:"not null"` PasswordHash string `gorm:"not null"`
DisplayName string `gorm:"size:200;not null"`
Role string `gorm:"size:32;not null"` Role string `gorm:"size:32;not null"`
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
@ -74,6 +75,7 @@ type Invitation struct {
ParticipantID uint `gorm:"index;not null"` ParticipantID uint `gorm:"index;not null"`
Token string `gorm:"uniqueIndex;size:64;not null"` Token string `gorm:"uniqueIndex;size:64;not null"`
Status string `gorm:"size:32;not null;default:pending"` Status string `gorm:"size:32;not null;default:pending"`
Outcome string `gorm:"size:32;not null;default:pending"`
DurationMinutes int `gorm:"not null"` DurationMinutes int `gorm:"not null"`
AcceptedAt *time.Time AcceptedAt *time.Time
CompletedAt *time.Time CompletedAt *time.Time
@ -84,6 +86,46 @@ type Invitation struct {
UpdatedAt time.Time 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 { type InvitationAnswer struct {
ID uint `gorm:"primaryKey"` ID uint `gorm:"primaryKey"`
InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"` InvitationID uint `gorm:"uniqueIndex:invitation_question;not null"`

View file

@ -6,7 +6,10 @@ import (
) )
func UserToService(value dbmodel.User) servicemodel.User { 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 { 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, ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName, ParticipantName: value.Participant.FirstName + " " + value.Participant.LastName,
ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status, ParticipantEmail: value.Participant.Email, Token: value.Token, Status: value.Status,
Outcome: value.Outcome,
DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt, DurationMinutes: value.DurationMinutes, AcceptedAt: value.AcceptedAt,
CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, 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,
}
}

View file

@ -17,10 +17,13 @@ import (
var ( var (
ErrNotFound = errors.New("record not found") ErrNotFound = errors.New("record not found")
ErrConflict = errors.New("record conflicts with existing data") ErrConflict = errors.New("record conflicts with existing data")
ErrInterviewNotStarted = errors.New("interview is not started")
) )
type IStore interface { type IStore interface {
FindUserByEmail(context.Context, string) (servicemodel.User, error) 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) ListJobs(context.Context) ([]servicemodel.JobPosting, error)
GetJob(context.Context, uint) (servicemodel.JobPosting, error) GetJob(context.Context, uint) (servicemodel.JobPosting, error)
CreateJob(context.Context, servicemodel.JobPosting) (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 AcceptInvitation(context.Context, string, time.Time) error
SaveAnswer(context.Context, string, uint, string) error SaveAnswer(context.Context, string, uint, string) error
CompleteInvitation(context.Context, string, time.Time) 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 { type Store struct {
@ -60,6 +76,32 @@ func (s *Store) FindUserByEmail(ctx context.Context, email string) (servicemodel
return dbmapper.UserToService(value), nil 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) { func (s *Store) ListJobs(ctx context.Context) ([]servicemodel.JobPosting, error) {
var values []dbmodel.JobPosting var values []dbmodel.JobPosting
if err := preloadJob(s.db.WithContext(ctx)).Order("created_at DESC").Find(&values).Error; err != nil { 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 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) { func (s *Store) resolveTraits(ctx context.Context, values []servicemodel.Trait) ([]dbmodel.Trait, error) {
result := make([]dbmodel.Trait, 0, len(values)) result := make([]dbmodel.Trait, 0, len(values))
seen := make(map[string]struct{}, len(values)) seen := make(map[string]struct{}, len(values))
@ -398,6 +635,23 @@ func preloadJob(db *gorm.DB) *gorm.DB {
Preload("Questions.Options") 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 { func mapError(err error) error {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound return ErrNotFound

View file

@ -1,6 +1,16 @@
INSERT INTO users (email, password_hash, role) INSERT INTO users (email, password_hash, display_name, role)
VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'interviewer') VALUES ('interviewer@go-guess.local', crypt('admin123', gen_salt('bf')), 'Lead Interviewer', 'interviewer')
ON CONFLICT (email) DO NOTHING; 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) INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.', SELECT 'Senior Go Backend Engineer', 'Build reliable APIs for passenger-facing railway systems.',

View file

@ -1,6 +1,9 @@
INSERT INTO users (email, password_hash, role) INSERT INTO users (email, password_hash, display_name, role)
VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'interviewer') VALUES ('integration@example.com', crypt('integration-password', gen_salt('bf')), 'Integration Interviewer', 'interviewer')
ON CONFLICT (email) DO NOTHING; 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) INSERT INTO job_postings (title, description, seniority, position, status, duration_minutes)
SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.', SELECT 'Integration Backend Engineer', 'Deterministic job fixture for API and UI integration tests.',

View 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
}

View 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
}

View file

@ -14,6 +14,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"testing" "testing"
"time" "time"
@ -26,6 +27,7 @@ import (
"github.com/danyel/go-guess/backend/internal/database/repository" "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/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/security"
"github.com/danyel/go-guess/backend/internal/service" "github.com/danyel/go-guess/backend/internal/service"
"github.com/danyel/go-guess/backend/internal/web/handler" "github.com/danyel/go-guess/backend/internal/web/handler"
@ -33,12 +35,32 @@ import (
"github.com/danyel/go-guess/backend/internal/web/router" "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) { func TestMain(m *testing.M) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() 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( container, err := tcpostgres.Run(
ctx, ctx,
"postgres:18-alpine", "postgres:18-alpine",
@ -53,6 +75,7 @@ func TestMain(m *testing.M) {
) )
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err) fmt.Fprintln(os.Stderr, "start PostgreSQL test container:", err)
_ = rabbitContainer.Terminate(context.Background())
os.Exit(1) os.Exit(1)
} }
@ -95,6 +118,19 @@ func TestMain(m *testing.M) {
store := repository.New(gormDB) store := repository.New(gormDB)
tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour) tokens := security.NewTokenManager("integration-test-secret-at-least-32-bytes", time.Hour)
invitations := service.NewInvitationService(store, "http://example.test") 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( testServer = httptest.NewServer(router.New(
handler.New( handler.New(
service.NewAuthService(store, tokens), service.NewAuthService(store, tokens),
@ -102,6 +138,8 @@ func TestMain(m *testing.M) {
service.NewQuestionService(store), service.NewQuestionService(store),
service.NewParticipantService(store), service.NewParticipantService(store),
invitations, invitations,
service.NewUserService(store),
interviews,
10, 10,
"http://example.test", "http://example.test",
), ),
@ -111,9 +149,11 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
testServer.Close() testServer.Close()
_ = amqpBus.Close()
_ = sqlDB.Close() _ = sqlDB.Close()
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = container.Terminate(cleanupCtx) _ = container.Terminate(cleanupCtx)
_ = rabbitContainer.Terminate(cleanupCtx)
cleanupCancel() cleanupCancel()
os.Exit(code) os.Exit(code)
} }
@ -221,6 +261,109 @@ func TestCompleteInterviewWorkflow(t *testing.T) {
if !found { if !found {
t.Fatalf("review did not include interviewer question context: %#v", review.Job.Questions) 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, &note)
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, &notes)
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( func requestJSON(

View file

@ -1,6 +1,7 @@
package security package security
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"time" "time"
@ -18,6 +19,22 @@ type Claims struct {
jwt.RegisteredClaims 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 { type ITokenManager interface {
Issue(uint, string, string) (string, error) Issue(uint, string, string) (string, error)
Parse(string) (Claims, error) Parse(string) (Claims, error)

View 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
}

View file

@ -6,6 +6,7 @@ type User struct {
ID uint ID uint
Email string Email string
PasswordHash string PasswordHash string
DisplayName string
Role string Role string
} }
@ -69,12 +70,53 @@ type Invitation struct {
ParticipantEmail string ParticipantEmail string
Token string Token string
Status string Status string
Outcome string
DurationMinutes int DurationMinutes int
AcceptedAt *time.Time AcceptedAt *time.Time
CompletedAt *time.Time CompletedAt *time.Time
CreatedAt 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 { type Interview struct {
Invitation Invitation Invitation Invitation
Job JobPosting Job JobPosting

View file

@ -21,6 +21,7 @@ var (
ErrInvalidQuestion = errors.New("invalid question") ErrInvalidQuestion = errors.New("invalid question")
ErrValidation = errors.New("validation failed") ErrValidation = errors.New("validation failed")
ErrInvalidState = errors.New("operation is not allowed in the current state") ErrInvalidState = errors.New("operation is not allowed in the current state")
ErrForbidden = errors.New("operation is forbidden")
) )
const CandidateThreshold = 60.0 const CandidateThreshold = 60.0
@ -60,6 +61,7 @@ type IInvitationService interface {
Accept(context.Context, string) (model.Interview, error) Accept(context.Context, string) (model.Interview, error)
SaveAnswer(context.Context, string, uint, string) error SaveAnswer(context.Context, string, uint, string) error
Finish(context.Context, string) (model.Interview, error) Finish(context.Context, string) (model.Interview, error)
SetOutcome(context.Context, uint, uint, string) (model.Invitation, error)
} }
type AuthService struct { type AuthService struct {
@ -460,6 +462,25 @@ func (s *InvitationService) Finish(ctx context.Context, token string) (model.Int
return s.GetInterview(ctx, token) 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 { func interviewExpired(interview model.Interview, now time.Time) bool {
return interview.Invitation.AcceptedAt != nil && return interview.Invitation.AcceptedAt != nil &&
!now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute)) !now.Before(interview.Invitation.AcceptedAt.Add(time.Duration(interview.Job.DurationMinutes)*time.Minute))

View file

@ -7,6 +7,8 @@ import (
"time" "time"
"github.com/danyel/go-guess/backend/internal/database/repository" "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/danyel/go-guess/backend/internal/service/model"
) )
@ -14,6 +16,7 @@ type fakeStore struct {
job model.JobPosting job model.JobPosting
participants []model.Participant participants []model.Participant
traits []model.Trait traits []model.Trait
createdUser model.User
} }
type invitationStore struct { type invitationStore struct {
@ -23,6 +26,58 @@ type invitationStore struct {
saved bool 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) { func (f *invitationStore) CreateInvitation(_ context.Context, value model.Invitation) (model.Invitation, error) {
value.ID = 1 value.ID = 1
return value, nil 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) { func (f *fakeStore) FindUserByEmail(context.Context, string) (model.User, error) {
return model.User{}, errors.New("unused") 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) 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) GetJob(context.Context, uint) (model.JobPosting, error) { return f.job, nil }
func (f *fakeStore) CreateJob(_ context.Context, value model.JobPosting) (model.JobPosting, error) { 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 { func (f *fakeStore) CompleteInvitation(context.Context, string, time.Time) error {
return nil 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) { func TestCandidatesRequireSixtyPercent(t *testing.T) {
store := &fakeStore{ store := &fakeStore{
@ -207,3 +318,107 @@ func TestInvitationReviewIsScopedToJob(t *testing.T) {
t.Fatalf("expected missing invitation error, got %v", err) 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")
}
}

View file

@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/danyel/go-guess/backend/internal/database/repository" "github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service" "github.com/danyel/go-guess/backend/internal/service"
servicemodel "github.com/danyel/go-guess/backend/internal/service/model" servicemodel "github.com/danyel/go-guess/backend/internal/service/model"
webmapper "github.com/danyel/go-guess/backend/internal/web/mapper" webmapper "github.com/danyel/go-guess/backend/internal/web/mapper"
@ -27,6 +28,8 @@ type Handler struct {
questions service.IQuestionService questions service.IQuestionService
participants service.IParticipantService participants service.IParticipantService
invitations service.IInvitationService invitations service.IInvitationService
users service.IUserService
interviews service.IScheduledInterviewService
maxUpload int64 maxUpload int64
frontendURL string frontendURL string
} }
@ -37,12 +40,15 @@ func New(
questions service.IQuestionService, questions service.IQuestionService,
participants service.IParticipantService, participants service.IParticipantService,
invitations service.IInvitationService, invitations service.IInvitationService,
users service.IUserService,
interviews service.IScheduledInterviewService,
maxUploadMB int64, maxUploadMB int64,
frontendURL string, frontendURL string,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
auth: auth, jobs: jobs, questions: questions, participants: participants, auth: auth, jobs: jobs, questions: questions, participants: participants,
invitations: invitations, maxUpload: maxUploadMB << 20, invitations: invitations, maxUpload: maxUploadMB << 20,
users: users, interviews: interviews,
frontendURL: strings.TrimRight(frontendURL, "/"), frontendURL: strings.TrimRight(frontendURL, "/"),
} }
} }
@ -67,10 +73,36 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, webmodel.LoginResponse{ 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) { func (h *Handler) ListJobs(w http.ResponseWriter, r *http.Request) {
values, err := h.jobs.List(r.Context()) values, err := h.jobs.List(r.Context())
if err != nil { 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)) 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) { func (h *Handler) GetInterview(w http.ResponseWriter, r *http.Request) {
value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token")) value, err := h.invitations.GetInterview(r.Context(), chi.URLParam(r, "token"))
if handleServiceError(w, r, err) { 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) writeError(w, http.StatusConflict, err)
case errors.Is(err, service.ErrNotAvailable): case errors.Is(err, service.ErrNotAvailable):
writeError(w, http.StatusNotFound, err) writeError(w, http.StatusNotFound, err)
case errors.Is(err, service.ErrForbidden):
writeError(w, http.StatusForbidden, err)
default: default:
internalError(w, r, err) internalError(w, r, err)
} }
return true 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) { 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) slog.ErrorContext(r.Context(), "request failed", "method", r.Method, "path", r.URL.Path, "error", err)
writeError(w, http.StatusInternalServerError, errors.New("internal server error")) writeError(w, http.StatusInternalServerError, errors.New("internal server error"))

View file

@ -85,11 +85,64 @@ func InvitationToWeb(value servicemodel.Invitation, frontendURL string) webmodel
ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID, ID: value.ID, JobID: value.JobID, ParticipantID: value.ParticipantID,
ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail, ParticipantName: value.ParticipantName, ParticipantEmail: value.ParticipantEmail,
Token: value.Token, Status: value.Status, Token: value.Token, Status: value.Status,
Outcome: value.Outcome,
ParticipantURL: frontendURL + "/participant/" + value.Token, ParticipantURL: frontendURL + "/participant/" + value.Token,
AcceptedAt: value.AcceptedAt, CompletedAt: value.CompletedAt, CreatedAt: value.CreatedAt, 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 { func InterviewToWeb(value servicemodel.Interview, frontendURL string) webmodel.InterviewResponse {
questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions)) questions := make([]webmodel.InterviewQuestionResponse, len(value.Job.Questions))
for i, question := range value.Job.Questions { for i, question := range value.Job.Questions {

View file

@ -19,9 +19,16 @@ type LoginResponse struct {
type User struct { type User struct {
ID uint `json:"id"` ID uint `json:"id"`
Email string `json:"email"` Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
} }
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
type JobRequest struct { type JobRequest struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
@ -108,6 +115,7 @@ type InvitationResponse struct {
ParticipantEmail string `json:"participantEmail"` ParticipantEmail string `json:"participantEmail"`
Token string `json:"token"` Token string `json:"token"`
Status string `json:"status"` Status string `json:"status"`
Outcome string `json:"outcome"`
ParticipantURL string `json:"participantUrl"` ParticipantURL string `json:"participantUrl"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"` AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CompletedAt *time.Time `json:"completedAt,omitempty"` CompletedAt *time.Time `json:"completedAt,omitempty"`
@ -147,3 +155,75 @@ type InvitationReviewResponse struct {
type AnswerRequest struct { type AnswerRequest struct {
Answer string `json:"answer"` 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"`
}

View file

@ -1,7 +1,6 @@
package router package router
import ( import (
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
@ -13,10 +12,6 @@ import (
"github.com/danyel/go-guess/backend/internal/web/handler" "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 { func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) 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.Post("/api/interviews/{token}/accept", h.AcceptInterview)
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer) router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
router.Post("/api/interviews/{token}/finish", h.FinishInterview) router.Post("/api/interviews/{token}/finish", h.FinishInterview)
router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting)
router.Group(func(protected chi.Router) { router.Group(func(protected chi.Router) {
protected.Use(authenticate(tokens)) protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs) 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.Get("/api/jobs/{id}/invitations", h.ListInvitations)
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation) 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.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion) protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion) protected.Put("/api/questions/{id}", h.UpdateQuestion)
@ -72,7 +82,7 @@ func authenticate(tokens security.ITokenManager) func(http.Handler) http.Handler
return return
} }
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims))) next.ServeHTTP(w, r.WithContext(security.WithClaims(r.Context(), claims)))
}) })
} }
} }

View file

@ -1,12 +1,15 @@
package router package router
import ( import (
"bytes"
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time" "time"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security" "github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service" "github.com/danyel/go-guess/backend/internal/service"
"github.com/danyel/go-guess/backend/internal/service/model" "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) { func (invitationStub) Finish(context.Context, string) (model.Interview, error) {
return model.Interview{}, nil 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) { 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) tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173") router := New(h, tokens, "http://localhost:5173")
request := httptest.NewRequest(http.MethodGet, "/api/health", nil) request := httptest.NewRequest(http.MethodGet, "/api/health", nil)
@ -91,7 +172,7 @@ func TestHealthDoesNotRequireAuthentication(t *testing.T) {
} }
func TestProtectedRouteRequiresValidToken(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) tokens := security.NewTokenManager("01234567890123456789012345678901", time.Hour)
router := New(h, tokens, "http://localhost:5173") 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" { 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")) t.Fatalf("expected JSON 401, got %d %q", unauthorized.Code, unauthorized.Header().Get("Content-Type"))
} }
token, err := tokens.Issue(1, "interviewer@example.com", "interviewer") token, err := tokens.Issue(1, "interviewer@example.com", "interviewer")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -113,3 +193,48 @@ func TestProtectedRouteRequiresValidToken(t *testing.T) {
t.Fatalf("expected 200, got %d", authorized.Code) 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)
}
}

View 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;

View file

@ -15,6 +15,17 @@ services:
timeout: 3s timeout: 3s
retries: 20 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: api:
build: ./backend build: ./backend
environment: environment:
@ -22,11 +33,14 @@ services:
JWT_SECRET: local-development-secret-change-me-12345 JWT_SECRET: local-development-secret-change-me-12345
FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:5173}
APP_ENV: ${APP_ENV:-development} APP_ENV: ${APP_ENV:-development}
RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
ports: ports:
- "${API_PORT:-8080}:8080" - "${API_PORT:-8080}:8080"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
rabbitmq:
condition: service_healthy
web: web:
build: ./frontend build: ./frontend

View file

@ -37,6 +37,8 @@ Supported routes:
- `GET /api/jobs/:id/candidates` - `GET /api/jobs/:id/candidates`
- `GET|POST /api/jobs/:id/invitations` - `GET|POST /api/jobs/:id/invitations`
- `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review - `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=`) - `GET|POST /api/questions` (`GET` accepts `?search=`)
- `PUT /api/questions/:id` to update question text, type, options, reference answer, and code snippet - `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 - `PATCH /api/questions/:id` to deprecate or restore a question
@ -48,6 +50,15 @@ Supported routes:
- `POST /api/interviews/:token/accept` - `POST /api/interviews/:token/accept`
- `PUT /api/interviews/:token/answers/:questionId` - `PUT /api/interviews/:token/answers/:questionId`
- `POST /api/interviews/:token/finish` - `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`, Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`. `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, 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 possible choice options, reference answers, and code-review context; unanswered questions are
called out explicitly. 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 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 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 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. 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 ## Container
Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and Build with `docker build -t go-guess-frontend .`. The production Nginx server serves the SPA and

View file

@ -58,7 +58,7 @@ function mockApi() {
if (path === '/api/auth/login') if (path === '/api/auth/login')
return response({ return response({
token: 'jwt-token', 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/jobs') return response([job])
if (path === '/api/participants') return response([participant]) 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/invitations' && method === 'GET') return response([])
if (path === '/api/jobs/7/interviews' && method === 'GET') return response([])
if (path === '/api/jobs/7/invitations' && method === 'POST') if (path === '/api/jobs/7/invitations' && method === 'POST')
return response({ return response({
id: 31, id: 31,
@ -110,7 +111,12 @@ function authenticate() {
sessionStorage.setItem('go-guess-token', 'jwt-token') sessionStorage.setItem('go-guess-token', 'jwt-token')
sessionStorage.setItem( sessionStorage.setItem(
'go-guess-user', '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.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
expect(screen.queryByText(codeQuestion.referenceAnswer)).not.toBeInTheDocument() 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()
})
}) })

View file

@ -2,6 +2,7 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
BriefcaseBusiness, BriefcaseBusiness,
CalendarDays,
Check, Check,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
@ -15,6 +16,7 @@ import {
Plus, Plus,
Search, Search,
Send, Send,
Inbox,
Sparkles, Sparkles,
Trash2, Trash2,
Upload, Upload,
@ -42,6 +44,14 @@ import {
useParams, useParams,
} from 'react-router-dom' } from 'react-router-dom'
import { api, ApiError, authStorage } from './api' import { api, ApiError, authStorage } from './api'
import {
CalendarPage,
InboxPage,
ParticipantMeetingPage,
ScheduleInterviewForm,
ScheduledInterviewPage,
UsersPage,
} from './interview-workflow'
import type { import type {
CandidateMatch, CandidateMatch,
CreateJobInput, CreateJobInput,
@ -54,6 +64,7 @@ import type {
Participant, Participant,
Question, Question,
QuestionType, QuestionType,
ScheduledInterview,
} from './types' } from './types'
interface AppData { interface AppData {
@ -276,6 +287,24 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
icon={<CircleHelp size={20} />} icon={<CircleHelp size={20} />}
close={() => setOpen(false)} 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> </nav>
<div className="sidebar-bottom"> <div className="sidebar-bottom">
<div className="user-chip"> <div className="user-chip">
@ -301,6 +330,10 @@ function AppLayout({ onLogout }: { onLogout: () => void }) {
<Route path="/participants" element={<ParticipantsPage />} /> <Route path="/participants" element={<ParticipantsPage />} />
<Route path="/participants/new" element={<CreateParticipantPage />} /> <Route path="/participants/new" element={<CreateParticipantPage />} />
<Route path="/participants/:participantId" element={<ParticipantDetailPage />} /> <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 />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</main> </main>
@ -458,6 +491,7 @@ function JobDetailPage() {
const [job, setJob] = useState<Job>() const [job, setJob] = useState<Job>()
const [matches, setMatches] = useState<CandidateMatch[]>([]) const [matches, setMatches] = useState<CandidateMatch[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([]) const [invitations, setInvitations] = useState<Invitation[]>([])
const [scheduledInterviews, setScheduledInterviews] = useState<ScheduledInterview[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>( const [tab, setTab] = useState<'overview' | 'questions' | 'candidates' | 'invitations'>(
@ -468,14 +502,16 @@ function JobDetailPage() {
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const [jobValue, candidates, invitationItems] = await Promise.all([ const [jobValue, candidates, invitationItems, scheduledItems] = await Promise.all([
api.jobs.get(id), api.jobs.get(id),
api.jobs.candidates(id), api.jobs.candidates(id),
api.jobs.invitations(id), api.jobs.invitations(id),
api.jobs.interviews(id),
]) ])
setJob(jobValue) setJob(jobValue)
setMatches(candidates) setMatches(candidates)
setInvitations(invitationItems) setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
} catch (requestError) { } catch (requestError) {
setError(errorMessage(requestError)) setError(errorMessage(requestError))
} finally { } finally {
@ -484,12 +520,18 @@ function JobDetailPage() {
} }
useEffect(() => { useEffect(() => {
let active = true let active = true
void Promise.all([api.jobs.get(id), api.jobs.candidates(id), api.jobs.invitations(id)]) void Promise.all([
.then(([jobValue, candidates, invitationItems]) => { api.jobs.get(id),
api.jobs.candidates(id),
api.jobs.invitations(id),
api.jobs.interviews(id),
])
.then(([jobValue, candidates, invitationItems, scheduledItems]) => {
if (!active) return if (!active) return
setJob(jobValue) setJob(jobValue)
setMatches(candidates) setMatches(candidates)
setInvitations(invitationItems) setInvitations(invitationItems)
setScheduledInterviews(scheduledItems)
}) })
.catch((requestError: unknown) => { .catch((requestError: unknown) => {
if (active) setError(errorMessage(requestError)) if (active) setError(errorMessage(requestError))
@ -563,7 +605,14 @@ function JobDetailPage() {
job={job} job={job}
matches={matches} matches={matches}
invitations={invitations} invitations={invitations}
scheduledInterviews={scheduledInterviews}
onInvited={(invitation) => setInvitations((current) => [invitation, ...current])} 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, job,
matches, matches,
invitations, invitations,
scheduledInterviews,
onInvited, onInvited,
onInvitationUpdated,
onScheduled,
}: { }: {
job: Job job: Job
matches: CandidateMatch[] matches: CandidateMatch[]
invitations: Invitation[] invitations: Invitation[]
scheduledInterviews: ScheduledInterview[]
onInvited: (invitation: Invitation) => void onInvited: (invitation: Invitation) => void
onInvitationUpdated: (invitation: Invitation) => void
onScheduled: (interview: ScheduledInterview) => void
}) { }) {
const [sendingId, setSendingId] = useState<number>() const [sendingId, setSendingId] = useState<number>()
const [review, setReview] = useState<Interview>() const [review, setReview] = useState<Interview>()
const [reviewingId, setReviewingId] = useState<number>() const [reviewingId, setReviewingId] = useState<number>()
const [error, setError] = useState('') const [error, setError] = useState('')
const [scheduling, setScheduling] = useState<Invitation>()
const invitedIds = new Set(invitations.map((invitation) => invitation.participantId)) const invitedIds = new Set(invitations.map((invitation) => invitation.participantId))
const eligible = matches.filter((match) => !invitedIds.has(match.participant.id)) 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." /> <EmptyState title="No invitations" description="Invite a candidate to this interview." />
)} )}
</div> </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> </section>
) )
} }
@ -871,10 +982,32 @@ function InvitationsView({
function InvitationAnswerReview({ function InvitationAnswerReview({
interview, interview,
onClose, onClose,
onOutcome,
}: { }: {
interview: Interview interview: Interview
onClose: () => void 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 ( return (
<section className="answer-review" aria-labelledby="answer-review-title"> <section className="answer-review" aria-labelledby="answer-review-title">
<div className="section-heading"> <div className="section-heading">
@ -883,6 +1016,32 @@ function InvitationAnswerReview({
<h2 id="answer-review-title">{interview.invitation.participantName}</h2> <h2 id="answer-review-title">{interview.invitation.participantName}</h2>
<p>{interview.invitation.participantEmail}</p> <p>{interview.invitation.participantEmail}</p>
</div> </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"> <div className="review-heading-actions">
<StatusBadge value={interview.invitation.status} /> <StatusBadge value={interview.invitation.status} />
<button className="button ghost" type="button" onClick={onClose}> <button className="button ghost" type="button" onClick={onClose}>
@ -2247,6 +2406,7 @@ export default function App() {
if (location.pathname.startsWith('/participant/')) { if (location.pathname.startsWith('/participant/')) {
return ( return (
<Routes> <Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} /> <Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View file

@ -9,7 +9,7 @@ describe('API client', () => {
it('sends the persisted bearer token on protected requests', async () => { it('sends the persisted bearer token on protected requests', async () => {
authStorage.save({ authStorage.save({
token: 'signed-jwt', 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 })) const fetchMock = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetchMock) vi.stubGlobal('fetch', fetchMock)
@ -27,7 +27,7 @@ describe('API client', () => {
it('uses the backend participant multipart field names', async () => { it('uses the backend participant multipart field names', async () => {
authStorage.save({ authStorage.save({
token: 'signed-jwt', 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( const fetchMock = vi.fn().mockResolvedValue(
new Response( new Response(

View file

@ -4,12 +4,17 @@ import type {
CreateJobInput, CreateJobInput,
CreateParticipantInput, CreateParticipantInput,
CreateQuestionInput, CreateQuestionInput,
CreateScheduledInterviewInput,
Interview, Interview,
Invitation, Invitation,
Job, Job,
JobStatus, JobStatus,
Participant, Participant,
ParticipantMeeting,
Question, Question,
ScheduledInterview,
User,
InterviewNote,
} from './types' } from './types'
const TOKEN_KEY = 'go-guess-token' const TOKEN_KEY = 'go-guess-token'
@ -131,6 +136,17 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ participantId }), 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: { questions: {
list: (search = '') => list: (search = '') =>
@ -178,4 +194,76 @@ export const api = {
return request<Participant>('/participants', { method: 'POST', body: form }) 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),
},
} }

View 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>
)
}

View file

@ -767,6 +767,33 @@ td small {
.button.danger { .button.danger {
color: #934141; 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, .panel-padding,
.invite-form { .invite-form {
padding: 1.2rem 1.5rem; padding: 1.2rem 1.5rem;
@ -909,6 +936,215 @@ td small {
.answer-review { .answer-review {
border-top: 1px solid var(--line); 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 { .review-heading-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1610,7 +1846,8 @@ fieldset {
@media (max-width: 1100px) { @media (max-width: 1100px) {
.split-panels, .split-panels,
.library-grid { .library-grid,
.session-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.library-form { .library-form {
@ -1797,6 +2034,15 @@ fieldset {
align-items: flex-start; align-items: flex-start;
flex-direction: column; 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) { @media (prefers-reduced-motion: reduce) {

View file

@ -3,6 +3,7 @@ export type QuestionType = 'open' | 'multiple_choice' | 'radio' | 'code_review'
export interface User { export interface User {
id: number id: number
email: string email: string
displayName: string
role: string role: string
} }
@ -67,12 +68,62 @@ export interface Invitation {
participantEmail: string participantEmail: string
token: string token: string
status: string status: string
outcome: 'pending' | 'passed' | 'failed'
participantUrl: string participantUrl: string
acceptedAt: string | null acceptedAt: string | null
completedAt: string | null completedAt: string | null
createdAt: string 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 { export interface Interview {
invitation: Invitation invitation: Invitation
job: Pick< job: Pick<