feat: add collaborative interview scheduling

Add candidate decisions, co-interviewer accounts, scheduled interview inbox and calendar views, candidate-safe meeting links, shared documents, and private live notes backed by RabbitMQ.

Extend the disposable integration environment to validate PostgreSQL migrations, seeded users, interview lifecycle behavior, and RabbitMQ fanout.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-09-19 21:58:24 +02:00
parent fd06fbbb5d
commit 75e3c73a61
35 changed files with 3621 additions and 58 deletions

View file

@ -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() {

View file

@ -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

View file

@ -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=

View file

@ -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")

View file

@ -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"`

View file

@ -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,
}
}

View file

@ -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

View file

@ -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.',

View file

@ -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.',

View file

@ -0,0 +1,212 @@
package eventbus
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
amqp "github.com/rabbitmq/amqp091-go"
)
const interviewExchange = "go-guess.interview-notes"
type AMQPBus struct {
url string
mu sync.Mutex
connection *amqp.Connection
publisher *amqp.Channel
consumer *amqp.Channel
subscribers map[chan InterviewNoteEvent]struct{}
done chan struct{}
closed bool
}
func NewAMQPBus(url string) (*AMQPBus, error) {
bus := &AMQPBus{
url: url, subscribers: make(map[chan InterviewNoteEvent]struct{}), done: make(chan struct{}),
}
deliveries, err := bus.connect()
if err != nil {
return nil, err
}
go bus.dispatch(deliveries)
return bus, nil
}
func (b *AMQPBus) connect() (<-chan amqp.Delivery, error) {
connection, err := amqp.Dial(b.url)
if err != nil {
return nil, fmt.Errorf("connect RabbitMQ: %w", err)
}
publisher, err := connection.Channel()
if err != nil {
_ = connection.Close()
return nil, fmt.Errorf("open RabbitMQ publisher: %w", err)
}
consumer, err := connection.Channel()
if err != nil {
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("open RabbitMQ consumer: %w", err)
}
for _, channel := range []*amqp.Channel{publisher, consumer} {
if err := channel.ExchangeDeclare(interviewExchange, "fanout", true, false, false, false, nil); err != nil {
_ = consumer.Close()
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("declare RabbitMQ exchange: %w", err)
}
}
if err := publisher.Confirm(false); err != nil {
_ = consumer.Close()
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("enable RabbitMQ publisher confirms: %w", err)
}
queue, err := consumer.QueueDeclare("", false, true, true, false, nil)
if err != nil {
_ = consumer.Close()
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("declare RabbitMQ queue: %w", err)
}
if err := consumer.QueueBind(queue.Name, "", interviewExchange, false, nil); err != nil {
_ = consumer.Close()
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("bind RabbitMQ queue: %w", err)
}
deliveries, err := consumer.Consume(queue.Name, "", true, true, false, false, nil)
if err != nil {
_ = consumer.Close()
_ = publisher.Close()
_ = connection.Close()
return nil, fmt.Errorf("consume RabbitMQ events: %w", err)
}
b.connection, b.publisher, b.consumer = connection, publisher, consumer
return deliveries, nil
}
func (b *AMQPBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error {
body, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("encode interview note event: %w", err)
}
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return ErrClosed
}
err = b.publishConfirmed(ctx, body)
if err == nil {
return nil
}
if b.consumer != nil {
_ = b.consumer.Close()
}
if b.publisher != nil {
_ = b.publisher.Close()
}
if b.connection != nil {
_ = b.connection.Close()
}
deliveries, reconnectErr := b.connect()
if reconnectErr != nil {
return fmt.Errorf("publish interview note: %w; reconnect: %v", err, reconnectErr)
}
go b.dispatch(deliveries)
if err := b.publishConfirmed(ctx, body); err != nil {
return fmt.Errorf("publish interview note after reconnect: %w", err)
}
return nil
}
func (b *AMQPBus) publishConfirmed(ctx context.Context, body []byte) error {
confirmation, err := b.publisher.PublishWithDeferredConfirmWithContext(
ctx, interviewExchange, "", false, false, amqp.Publishing{
ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body,
})
if err != nil {
return err
}
acknowledged, err := confirmation.WaitContext(ctx)
if err != nil {
return err
}
if !acknowledged {
return errors.New("RabbitMQ rejected interview note")
}
return nil
}
func (b *AMQPBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return nil, ErrClosed
}
ch := make(chan InterviewNoteEvent, 16)
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
go func() {
<-ctx.Done()
b.mu.Lock()
if _, ok := b.subscribers[ch]; ok {
delete(b.subscribers, ch)
close(ch)
}
b.mu.Unlock()
}()
return ch, nil
}
func (b *AMQPBus) dispatch(deliveries <-chan amqp.Delivery) {
for {
select {
case <-b.done:
return
case delivery, ok := <-deliveries:
if !ok {
return
}
var event InterviewNoteEvent
if json.Unmarshal(delivery.Body, &event) != nil {
continue
}
b.mu.Lock()
for subscriber := range b.subscribers {
select {
case subscriber <- event:
default:
}
}
b.mu.Unlock()
}
}
}
func (b *AMQPBus) Close() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil
}
b.closed = true
close(b.done)
for subscriber := range b.subscribers {
close(subscriber)
}
clear(b.subscribers)
if b.consumer != nil {
_ = b.consumer.Close()
}
if b.publisher != nil {
_ = b.publisher.Close()
}
if b.connection != nil {
return b.connection.Close()
}
return nil
}

View file

@ -0,0 +1,86 @@
package eventbus
import (
"context"
"errors"
"sync"
"time"
)
type InterviewNoteEvent struct {
InterviewID uint `json:"interviewId"`
ID uint `json:"id"`
AuthorUserID uint `json:"authorUserId"`
AuthorName string `json:"authorName"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
}
type IEventBus interface {
PublishInterviewNote(context.Context, InterviewNoteEvent) error
SubscribeInterviewNotes(context.Context) (<-chan InterviewNoteEvent, error)
Close() error
}
var ErrClosed = errors.New("event bus is closed")
type MemoryBus struct {
mu sync.RWMutex
subscribers map[chan InterviewNoteEvent]struct{}
closed bool
}
func NewMemoryBus() *MemoryBus {
return &MemoryBus{subscribers: make(map[chan InterviewNoteEvent]struct{})}
}
func (b *MemoryBus) PublishInterviewNote(ctx context.Context, event InterviewNoteEvent) error {
b.mu.RLock()
defer b.mu.RUnlock()
if b.closed {
return ErrClosed
}
for subscriber := range b.subscribers {
select {
case subscriber <- event:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}
func (b *MemoryBus) SubscribeInterviewNotes(ctx context.Context) (<-chan InterviewNoteEvent, error) {
b.mu.Lock()
if b.closed {
b.mu.Unlock()
return nil, ErrClosed
}
ch := make(chan InterviewNoteEvent, 16)
b.subscribers[ch] = struct{}{}
b.mu.Unlock()
go func() {
<-ctx.Done()
b.mu.Lock()
if _, ok := b.subscribers[ch]; ok {
delete(b.subscribers, ch)
close(ch)
}
b.mu.Unlock()
}()
return ch, nil
}
func (b *MemoryBus) Close() error {
b.mu.Lock()
defer b.mu.Unlock()
if b.closed {
return nil
}
b.closed = true
for subscriber := range b.subscribers {
close(subscriber)
}
clear(b.subscribers)
return nil
}

View file

@ -14,6 +14,7 @@ import (
"os"
"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, &note)
if note.AuthorUserID != coInterviewer.ID || note.AuthorName != "Co Interviewer" {
t.Fatalf("unexpected note response: %#v", note)
}
for index, events := range []<-chan eventbus.InterviewNoteEvent{firstEvents, secondEvents} {
select {
case received := <-events:
if received.ID != note.ID || received.AuthorUserID != coInterviewer.ID {
t.Fatalf("subscriber %d received unexpected RabbitMQ event: %#v", index+1, received)
}
encoded, err := json.Marshal(received)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(encoded, []byte(`"id":`)) || bytes.Contains(encoded, []byte(`"noteId":`)) {
t.Fatalf("subscriber %d received an incompatible note contract: %s", index+1, encoded)
}
case <-time.After(5 * time.Second):
t.Fatalf("subscriber %d did not receive RabbitMQ note event", index+1)
}
}
var notes []webmodel.InterviewNoteResponse
requestJSON(t, http.MethodGet, fmt.Sprintf("/api/scheduled-interviews/%d/notes", scheduled.ID),
login.Token, nil, http.StatusOK, &notes)
if len(notes) != 1 || notes[0].Body != note.Body {
t.Fatalf("persisted interview note missing: %#v", notes)
}
var meeting webmodel.ParticipantMeetingResponse
body := requestJSON(t, http.MethodGet, "/api/participant-meetings/"+scheduled.CandidateToken,
"", nil, http.StatusOK, &meeting)
if meeting.SharedDocument != "Collaborative notes" ||
bytes.Contains(body, []byte("participantEmail")) ||
bytes.Contains(body, []byte("referenceAnswer")) ||
bytes.Contains(body, []byte(`"notes":`)) {
t.Fatalf("invalid candidate-safe meeting response: %s", body)
}
}
func requestJSON(

View file

@ -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)

View file

@ -0,0 +1,261 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"github.com/danyel/go-guess/backend/internal/database/repository"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/service/model"
"github.com/google/uuid"
)
type IUserService interface {
List(context.Context) ([]model.User, error)
Create(context.Context, string, string, string) (model.User, error)
}
type IScheduledInterviewService interface {
Create(context.Context, uint, uint, model.ScheduledInterview, []uint) (model.ScheduledInterview, error)
List(context.Context, uint, uint) ([]model.ScheduledInterview, error)
Get(context.Context, uint, uint) (model.ScheduledInterview, error)
GetParticipantMeeting(context.Context, string) (model.ScheduledInterview, error)
UpdateStatus(context.Context, uint, uint, string) (model.ScheduledInterview, error)
UpdateDocument(context.Context, uint, uint, string) (model.ScheduledInterview, error)
ListNotes(context.Context, uint, uint) ([]model.InterviewNote, error)
CreateNote(context.Context, uint, uint, string) (model.InterviewNote, error)
ListInbox(context.Context, uint) ([]model.ScheduledInterview, error)
UpdateInbox(context.Context, uint, uint, string) (model.ScheduledInterview, error)
ListCalendar(context.Context, uint) ([]model.ScheduledInterview, error)
Subscribe(context.Context, uint, uint) (<-chan eventbus.InterviewNoteEvent, error)
}
type UserService struct {
store repository.IStore
}
func NewUserService(store repository.IStore) *UserService {
return &UserService{store: store}
}
func (s *UserService) List(ctx context.Context) ([]model.User, error) {
return s.store.ListUsers(ctx)
}
func (s *UserService) Create(ctx context.Context, email, password, displayName string) (model.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
displayName = strings.TrimSpace(displayName)
if email == "" || displayName == "" || len(password) < 8 {
return model.User{}, fmt.Errorf("%w: email, displayName, and an 8-character password are required", ErrValidation)
}
passwordHash, err := security.HashPassword(password)
if err != nil {
return model.User{}, err
}
user, err := s.store.CreateUser(ctx, model.User{
Email: email, PasswordHash: passwordHash, DisplayName: displayName, Role: "co_interviewer",
})
if err != nil {
return model.User{}, err
}
user.PasswordHash = ""
return user, nil
}
type ScheduledInterviewService struct {
store repository.IStore
bus eventbus.IEventBus
}
func NewScheduledInterviewService(store repository.IStore, bus eventbus.IEventBus) *ScheduledInterviewService {
return &ScheduledInterviewService{store: store, bus: bus}
}
func (s *ScheduledInterviewService) Create(
ctx context.Context,
userID, jobID uint,
value model.ScheduledInterview,
interviewerIDs []uint,
) (model.ScheduledInterview, error) {
invitation, err := s.store.GetInvitation(ctx, jobID, value.InvitationID)
if err != nil {
return model.ScheduledInterview{}, err
}
if invitation.Status != "completed" || invitation.Outcome != "passed" {
return model.ScheduledInterview{}, fmt.Errorf("%w: a passed completed assessment is required", ErrInvalidState)
}
value.Location = strings.TrimSpace(value.Location)
if value.StartsAt.IsZero() || value.Location == "" {
return model.ScheduledInterview{}, fmt.Errorf("%w: startsAt and location are required", ErrValidation)
}
users, err := s.store.ListUsers(ctx)
if err != nil {
return model.ScheduledInterview{}, err
}
validUsers := make(map[uint]struct{}, len(users))
for _, user := range users {
validUsers[user.ID] = struct{}{}
}
unique := map[uint]struct{}{userID: {}}
for _, id := range interviewerIDs {
if _, ok := validUsers[id]; !ok {
return model.ScheduledInterview{}, fmt.Errorf("%w: interviewer %d does not exist", ErrValidation, id)
}
unique[id] = struct{}{}
}
ids := make([]uint, 0, len(unique))
for id := range unique {
ids = append(ids, id)
}
value.JobID = jobID
value.ParticipantID = invitation.ParticipantID
value.CreatorID = userID
value.CandidateToken = uuid.NewString()
value.Status = "scheduled"
return s.store.CreateScheduledInterview(ctx, value, ids)
}
func (s *ScheduledInterviewService) List(
ctx context.Context, userID, jobID uint,
) ([]model.ScheduledInterview, error) {
values, err := s.store.ListScheduledInterviews(ctx, jobID)
if err != nil {
return nil, err
}
result := make([]model.ScheduledInterview, 0, len(values))
for _, value := range values {
if isAttendee(value, userID) {
result = append(result, value)
}
}
return result, nil
}
func (s *ScheduledInterviewService) Get(
ctx context.Context, userID, interviewID uint,
) (model.ScheduledInterview, error) {
value, err := s.store.GetScheduledInterview(ctx, interviewID)
if err != nil {
return model.ScheduledInterview{}, err
}
if !isAttendee(value, userID) {
return model.ScheduledInterview{}, ErrForbidden
}
return value, nil
}
func (s *ScheduledInterviewService) GetParticipantMeeting(
ctx context.Context, token string,
) (model.ScheduledInterview, error) {
return s.store.GetScheduledInterviewByToken(ctx, token)
}
func (s *ScheduledInterviewService) UpdateStatus(
ctx context.Context, userID, interviewID uint, status string,
) (model.ScheduledInterview, error) {
current, err := s.Get(ctx, userID, interviewID)
if err != nil {
return model.ScheduledInterview{}, err
}
valid := (current.Status == "scheduled" && (status == "started" || status == "cancelled")) ||
(current.Status == "started" && (status == "completed" || status == "cancelled"))
if !valid {
return model.ScheduledInterview{}, fmt.Errorf("%w: invalid interview status transition", ErrInvalidState)
}
return s.store.UpdateScheduledInterviewStatus(ctx, interviewID, status)
}
func (s *ScheduledInterviewService) UpdateDocument(
ctx context.Context, userID, interviewID uint, document string,
) (model.ScheduledInterview, error) {
if _, err := s.Get(ctx, userID, interviewID); err != nil {
return model.ScheduledInterview{}, err
}
return s.store.UpdateScheduledInterviewDocument(ctx, interviewID, document)
}
func (s *ScheduledInterviewService) ListNotes(
ctx context.Context, userID, interviewID uint,
) ([]model.InterviewNote, error) {
if _, err := s.Get(ctx, userID, interviewID); err != nil {
return nil, err
}
return s.store.ListInterviewNotes(ctx, interviewID)
}
func (s *ScheduledInterviewService) CreateNote(
ctx context.Context, userID, interviewID uint, body string,
) (model.InterviewNote, error) {
interview, err := s.Get(ctx, userID, interviewID)
if err != nil {
return model.InterviewNote{}, err
}
if interview.Status != "started" {
return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState)
}
body = strings.TrimSpace(body)
if body == "" {
return model.InterviewNote{}, fmt.Errorf("%w: note body is required", ErrValidation)
}
note, err := s.store.CreateInterviewNote(ctx, model.InterviewNote{
InterviewID: interviewID, AuthorID: userID, Body: body,
})
if err != nil {
if errors.Is(err, repository.ErrInterviewNotStarted) {
return model.InterviewNote{}, fmt.Errorf("%w: notes require a started interview", ErrInvalidState)
}
return model.InterviewNote{}, err
}
if err := s.bus.PublishInterviewNote(ctx, eventbus.InterviewNoteEvent{
InterviewID: note.InterviewID, ID: note.ID, AuthorUserID: note.AuthorID,
AuthorName: note.Author.DisplayName, Body: note.Body, CreatedAt: note.CreatedAt,
}); err != nil {
return model.InterviewNote{}, fmt.Errorf("publish persisted interview note: %w", err)
}
return note, nil
}
func (s *ScheduledInterviewService) ListInbox(
ctx context.Context, userID uint,
) ([]model.ScheduledInterview, error) {
return s.store.ListInbox(ctx, userID)
}
func (s *ScheduledInterviewService) UpdateInbox(
ctx context.Context, userID, interviewID uint, status string,
) (model.ScheduledInterview, error) {
if status != "accepted" && status != "declined" {
return model.ScheduledInterview{}, fmt.Errorf("%w: attendee status must be accepted or declined", ErrValidation)
}
if _, err := s.store.UpdateAttendeeStatus(ctx, interviewID, userID, status); err != nil {
return model.ScheduledInterview{}, err
}
return s.Get(ctx, userID, interviewID)
}
func (s *ScheduledInterviewService) ListCalendar(
ctx context.Context, userID uint,
) ([]model.ScheduledInterview, error) {
return s.store.ListCalendar(ctx, userID)
}
func (s *ScheduledInterviewService) Subscribe(
ctx context.Context, userID, interviewID uint,
) (<-chan eventbus.InterviewNoteEvent, error) {
if _, err := s.Get(ctx, userID, interviewID); err != nil {
return nil, err
}
return s.bus.SubscribeInterviewNotes(ctx)
}
func isAttendee(value model.ScheduledInterview, userID uint) bool {
for _, attendee := range value.Attendees {
if attendee.UserID == userID {
return true
}
}
return false
}

View file

@ -6,6 +6,7 @@ type User struct {
ID uint
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

View file

@ -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))

View file

@ -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")
}
}

View file

@ -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"))

View file

@ -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 {

View file

@ -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"`
}

View file

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

View file

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

View file

@ -0,0 +1,60 @@
-- +goose Up
ALTER TABLE users
ADD COLUMN display_name VARCHAR(200) NOT NULL DEFAULT '';
UPDATE users
SET display_name = split_part(email, '@', 1)
WHERE display_name = '';
ALTER TABLE invitations
ADD COLUMN outcome VARCHAR(32) NOT NULL DEFAULT 'pending'
CHECK (outcome IN ('pending', 'passed', 'failed'));
CREATE TABLE scheduled_interviews (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES job_postings(id) ON DELETE CASCADE,
invitation_id BIGINT NOT NULL UNIQUE REFERENCES invitations(id) ON DELETE RESTRICT,
participant_id BIGINT NOT NULL REFERENCES participants(id) ON DELETE RESTRICT,
creator_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
starts_at TIMESTAMPTZ NOT NULL,
location VARCHAR(500) NOT NULL,
candidate_token VARCHAR(64) NOT NULL UNIQUE,
shared_document TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'started', 'completed', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX scheduled_interviews_job_id_idx ON scheduled_interviews(job_id);
CREATE INDEX scheduled_interviews_participant_id_idx ON scheduled_interviews(participant_id);
CREATE TABLE interview_attendees (
id BIGSERIAL PRIMARY KEY,
interview_id BIGINT NOT NULL REFERENCES scheduled_interviews(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL DEFAULT 'invited'
CHECK (status IN ('invited', 'accepted', 'declined')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (interview_id, user_id)
);
CREATE INDEX interview_attendees_user_id_idx ON interview_attendees(user_id);
CREATE TABLE interview_notes (
id BIGSERIAL PRIMARY KEY,
interview_id BIGINT NOT NULL REFERENCES scheduled_interviews(id) ON DELETE CASCADE,
author_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX interview_notes_interview_id_idx ON interview_notes(interview_id);
-- +goose Down
DROP TABLE IF EXISTS interview_notes;
DROP TABLE IF EXISTS interview_attendees;
DROP TABLE IF EXISTS scheduled_interviews;
ALTER TABLE invitations DROP COLUMN outcome;
ALTER TABLE users DROP COLUMN display_name;