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