├── .env ├── cmd ├── tools │ └── terndotenv │ │ └── main.go └── wsrs │ └── main.go ├── compose.yml ├── docs └── Rooms.postman_collection.json ├── gen.go ├── go.mod ├── go.sum └── internal ├── api ├── api.go └── utils.go └── store └── pgstore ├── db.go ├── migrations ├── 001_create_rooms_table.sql ├── 002_create_messages_table.sql └── tern.conf ├── models.go ├── queries.sql.go ├── queries └── queries.sql └── sqlc.yaml /.env: -------------------------------------------------------------------------------- 1 | WSRS_DATABASE_PORT=5432 2 | WSRS_DATABASE_NAME="wsrs" 3 | WSRS_DATABASE_USER="postgres" 4 | WSRS_DATABASE_PASSWORD="123456789" 5 | WSRS_DATABASE_HOST="localhost" 6 | -------------------------------------------------------------------------------- /cmd/tools/terndotenv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | func main() { 10 | if err := godotenv.Load(); err != nil { 11 | panic(err) 12 | } 13 | 14 | cmd := exec.Command( 15 | "tern", 16 | "migrate", 17 | "--migrations", 18 | "./internal/store/pgstore/migrations", 19 | "--config", 20 | "./internal/store/pgstore/migrations/tern.conf", 21 | ) 22 | if err := cmd.Run(); err != nil { 23 | panic(err) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/wsrs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | 11 | "github.com/rocketseat-education/semana-tech-go-react-server/internal/api" 12 | "github.com/rocketseat-education/semana-tech-go-react-server/internal/store/pgstore" 13 | 14 | "github.com/jackc/pgx/v5/pgxpool" 15 | "github.com/joho/godotenv" 16 | ) 17 | 18 | func main() { 19 | if err := godotenv.Load(); err != nil { 20 | panic(err) 21 | } 22 | 23 | ctx := context.Background() 24 | 25 | pool, err := pgxpool.New(ctx, fmt.Sprintf( 26 | "user=%s password=%s host=%s port=%s dbname=%s", 27 | os.Getenv("WSRS_DATABASE_USER"), 28 | os.Getenv("WSRS_DATABASE_PASSWORD"), 29 | os.Getenv("WSRS_DATABASE_HOST"), 30 | os.Getenv("WSRS_DATABASE_PORT"), 31 | os.Getenv("WSRS_DATABASE_NAME"), 32 | )) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | defer pool.Close() 38 | 39 | if err := pool.Ping(ctx); err != nil { 40 | panic(err) 41 | } 42 | 43 | handler := api.NewHandler(pgstore.New(pool)) 44 | 45 | go func() { 46 | if err := http.ListenAndServe(":8080", handler); err != nil { 47 | if !errors.Is(err, http.ErrServerClosed) { 48 | panic(err) 49 | } 50 | } 51 | }() 52 | 53 | quit := make(chan os.Signal, 1) 54 | signal.Notify(quit, os.Interrupt) 55 | <-quit 56 | } 57 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:latest 4 | restart: unless-stopped 5 | ports: 6 | - ${WSRS_DATABASE_PORT:-5432}:5432 7 | environment: 8 | POSTGRES_USER: ${WSRS_DATABASE_USER} 9 | POSTGRES_PASSWORD: ${WSRS_DATABASE_PASSWORD} 10 | POSTGRES_DB: ${WSRS_DATABASE_NAME} 11 | volumes: 12 | - db:/var/lib/postgresql/data 13 | 14 | pgadmin: 15 | image: dpage/pgadmin4:latest 16 | restart: unless-stopped 17 | depends_on: 18 | - db 19 | ports: 20 | - 8081:80 21 | environment: 22 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 23 | PGADMIN_DEFAULT_PASSWORD: password 24 | volumes: 25 | - pgadmin:/var/lib/pgadmin 26 | 27 | volumes: 28 | db: 29 | driver: local 30 | pgadmin: 31 | driver: local 32 | -------------------------------------------------------------------------------- /docs/Rooms.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "3fcebff2-4cb7-4d58-af11-fbd64dc33598", 4 | "name": "Rooms", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", 6 | "_exporter_id": "26324417" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Messages", 11 | "item": [ 12 | { 13 | "name": "Create a new message", 14 | "request": { 15 | "method": "POST", 16 | "header": [], 17 | "body": { 18 | "mode": "raw", 19 | "raw": "{\n \"message\": \"this is a test message\"\n}", 20 | "options": { 21 | "raw": { 22 | "language": "json" 23 | } 24 | } 25 | }, 26 | "url": { 27 | "raw": "http://localhost:8080/api/rooms/:room_id/messages", 28 | "protocol": "http", 29 | "host": [ 30 | "localhost" 31 | ], 32 | "port": "8080", 33 | "path": [ 34 | "api", 35 | "rooms", 36 | ":room_id", 37 | "messages" 38 | ], 39 | "variable": [ 40 | { 41 | "key": "room_id", 42 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 43 | } 44 | ] 45 | } 46 | }, 47 | "response": [] 48 | }, 49 | { 50 | "name": "Get a specific message", 51 | "request": { 52 | "method": "GET", 53 | "header": [], 54 | "url": { 55 | "raw": "http://localhost:8080/api/rooms/:room_id/messages/:message_id", 56 | "protocol": "http", 57 | "host": [ 58 | "localhost" 59 | ], 60 | "port": "8080", 61 | "path": [ 62 | "api", 63 | "rooms", 64 | ":room_id", 65 | "messages", 66 | ":message_id" 67 | ], 68 | "variable": [ 69 | { 70 | "key": "room_id", 71 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 72 | }, 73 | { 74 | "key": "message_id", 75 | "value": "28e23a25-c58e-4737-b6f7-f01da5896d5a" 76 | } 77 | ] 78 | } 79 | }, 80 | "response": [] 81 | }, 82 | { 83 | "name": "Add 1 reaction", 84 | "request": { 85 | "method": "PATCH", 86 | "header": [], 87 | "url": { 88 | "raw": "http://localhost:8080/api/rooms/:room_id/messages/:message_id/react", 89 | "protocol": "http", 90 | "host": [ 91 | "localhost" 92 | ], 93 | "port": "8080", 94 | "path": [ 95 | "api", 96 | "rooms", 97 | ":room_id", 98 | "messages", 99 | ":message_id", 100 | "react" 101 | ], 102 | "variable": [ 103 | { 104 | "key": "room_id", 105 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 106 | }, 107 | { 108 | "key": "message_id", 109 | "value": "28e23a25-c58e-4737-b6f7-f01da5896d5a" 110 | } 111 | ] 112 | } 113 | }, 114 | "response": [] 115 | }, 116 | { 117 | "name": "Remove 1 reaction", 118 | "request": { 119 | "method": "DELETE", 120 | "header": [], 121 | "url": { 122 | "raw": "http://localhost:8080/api/rooms/:room_id/messages/:message_id/react", 123 | "protocol": "http", 124 | "host": [ 125 | "localhost" 126 | ], 127 | "port": "8080", 128 | "path": [ 129 | "api", 130 | "rooms", 131 | ":room_id", 132 | "messages", 133 | ":message_id", 134 | "react" 135 | ], 136 | "variable": [ 137 | { 138 | "key": "room_id", 139 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 140 | }, 141 | { 142 | "key": "message_id", 143 | "value": "28e23a25-c58e-4737-b6f7-f01da5896d5a" 144 | } 145 | ] 146 | } 147 | }, 148 | "response": [] 149 | }, 150 | { 151 | "name": "Mark as answered", 152 | "request": { 153 | "method": "PATCH", 154 | "header": [], 155 | "url": { 156 | "raw": "http://localhost:8080/api/rooms/:room_id/messages/:message_id/answer", 157 | "protocol": "http", 158 | "host": [ 159 | "localhost" 160 | ], 161 | "port": "8080", 162 | "path": [ 163 | "api", 164 | "rooms", 165 | ":room_id", 166 | "messages", 167 | ":message_id", 168 | "answer" 169 | ], 170 | "variable": [ 171 | { 172 | "key": "room_id", 173 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 174 | }, 175 | { 176 | "key": "message_id", 177 | "value": "28e23a25-c58e-4737-b6f7-f01da5896d5a" 178 | } 179 | ] 180 | } 181 | }, 182 | "response": [] 183 | } 184 | ] 185 | }, 186 | { 187 | "name": "Create room", 188 | "request": { 189 | "method": "POST", 190 | "header": [], 191 | "body": { 192 | "mode": "raw", 193 | "raw": "{\n \"theme\": \"this is a test room\"\n}", 194 | "options": { 195 | "raw": { 196 | "language": "json" 197 | } 198 | } 199 | }, 200 | "url": { 201 | "raw": "http://localhost:8080/api/rooms", 202 | "protocol": "http", 203 | "host": [ 204 | "localhost" 205 | ], 206 | "port": "8080", 207 | "path": [ 208 | "api", 209 | "rooms" 210 | ] 211 | } 212 | }, 213 | "response": [] 214 | }, 215 | { 216 | "name": "Get all rooms", 217 | "request": { 218 | "method": "GET", 219 | "header": [], 220 | "url": { 221 | "raw": "http://localhost:8080/api/rooms", 222 | "protocol": "http", 223 | "host": [ 224 | "localhost" 225 | ], 226 | "port": "8080", 227 | "path": [ 228 | "api", 229 | "rooms" 230 | ] 231 | } 232 | }, 233 | "response": [] 234 | }, 235 | { 236 | "name": "Get a room", 237 | "request": { 238 | "method": "GET", 239 | "header": [], 240 | "url": { 241 | "raw": "http://localhost:8080/api/rooms/:room_id", 242 | "protocol": "http", 243 | "host": [ 244 | "localhost" 245 | ], 246 | "port": "8080", 247 | "path": [ 248 | "api", 249 | "rooms", 250 | ":room_id" 251 | ], 252 | "variable": [ 253 | { 254 | "key": "room_id", 255 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 256 | } 257 | ] 258 | } 259 | }, 260 | "response": [] 261 | }, 262 | { 263 | "name": "Get all room messages", 264 | "request": { 265 | "method": "GET", 266 | "header": [], 267 | "url": { 268 | "raw": "http://localhost:8080/api/rooms/:room_id/messages", 269 | "protocol": "http", 270 | "host": [ 271 | "localhost" 272 | ], 273 | "port": "8080", 274 | "path": [ 275 | "api", 276 | "rooms", 277 | ":room_id", 278 | "messages" 279 | ], 280 | "variable": [ 281 | { 282 | "key": "room_id", 283 | "value": "29ca3de2-f116-4299-9345-7c07510db9bb" 284 | } 285 | ] 286 | } 287 | }, 288 | "response": [] 289 | } 290 | ] 291 | } -------------------------------------------------------------------------------- /gen.go: -------------------------------------------------------------------------------- 1 | package gen 2 | 3 | //go:generate go run ./cmd/tools/terndotenv/main.go 4 | //go:generate sqlc generate -f ./internal/store/pgstore/sqlc.yaml 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rocketseat-education/semana-tech-go-react-server 2 | 3 | go 1.22.4 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.1.0 7 | github.com/go-chi/cors v1.2.1 8 | github.com/google/uuid v1.6.0 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/jackc/pgx/v5 v5.6.0 11 | github.com/joho/godotenv v1.5.1 12 | ) 13 | 14 | require ( 15 | github.com/jackc/pgpassfile v1.0.0 // indirect 16 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 17 | github.com/jackc/puddle/v2 v2.2.1 // indirect 18 | golang.org/x/crypto v0.17.0 // indirect 19 | golang.org/x/sync v0.1.0 // indirect 20 | golang.org/x/text v0.14.0 // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 5 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 6 | github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= 7 | github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 8 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 9 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 10 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 11 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 12 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 13 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 14 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 15 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 16 | github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= 17 | github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= 18 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 19 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 20 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 21 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 25 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 26 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 28 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 29 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 30 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 31 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 32 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 33 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 34 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 35 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 36 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /internal/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "log/slog" 8 | "net/http" 9 | "sync" 10 | 11 | "github.com/rocketseat-education/semana-tech-go-react-server/internal/store/pgstore" 12 | 13 | "github.com/go-chi/chi/v5" 14 | "github.com/go-chi/chi/v5/middleware" 15 | "github.com/go-chi/cors" 16 | "github.com/google/uuid" 17 | "github.com/gorilla/websocket" 18 | "github.com/jackc/pgx/v5" 19 | ) 20 | 21 | type apiHandler struct { 22 | q *pgstore.Queries 23 | r *chi.Mux 24 | upgrader websocket.Upgrader 25 | subscribers map[string]map[*websocket.Conn]context.CancelFunc 26 | mu *sync.Mutex 27 | } 28 | 29 | func (h apiHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 30 | h.r.ServeHTTP(w, r) 31 | } 32 | 33 | func NewHandler(q *pgstore.Queries) http.Handler { 34 | a := apiHandler{ 35 | q: q, 36 | upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}, 37 | subscribers: make(map[string]map[*websocket.Conn]context.CancelFunc), 38 | mu: &sync.Mutex{}, 39 | } 40 | 41 | r := chi.NewRouter() 42 | r.Use(middleware.RequestID, middleware.Recoverer, middleware.Logger) 43 | 44 | r.Use(cors.Handler(cors.Options{ 45 | AllowedOrigins: []string{"https://*", "http://*"}, 46 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, 47 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, 48 | ExposedHeaders: []string{"Link"}, 49 | AllowCredentials: false, 50 | MaxAge: 300, 51 | })) 52 | 53 | r.Get("/subscribe/{room_id}", a.handleSubscribe) 54 | 55 | r.Route("/api", func(r chi.Router) { 56 | r.Route("/rooms", func(r chi.Router) { 57 | r.Post("/", a.handleCreateRoom) 58 | r.Get("/", a.handleGetRooms) 59 | 60 | r.Route("/{room_id}", func(r chi.Router) { 61 | r.Get("/", a.handleGetRoom) 62 | 63 | r.Route("/messages", func(r chi.Router) { 64 | r.Post("/", a.handleCreateRoomMessage) 65 | r.Get("/", a.handleGetRoomMessages) 66 | 67 | r.Route("/{message_id}", func(r chi.Router) { 68 | r.Get("/", a.handleGetRoomMessage) 69 | r.Patch("/react", a.handleReactToMessage) 70 | r.Delete("/react", a.handleRemoveReactFromMessage) 71 | r.Patch("/answer", a.handleMarkMessageAsAnswered) 72 | }) 73 | }) 74 | }) 75 | }) 76 | }) 77 | 78 | a.r = r 79 | return a 80 | } 81 | 82 | const ( 83 | MessageKindMessageCreated = "message_created" 84 | MessageKindMessageRactionIncreased = "message_reaction_increased" 85 | MessageKindMessageRactionDecreased = "message_reaction_decreased" 86 | MessageKindMessageAnswered = "message_answered" 87 | ) 88 | 89 | type MessageMessageReactionIncreased struct { 90 | ID string `json:"id"` 91 | Count int64 `json:"count"` 92 | } 93 | 94 | type MessageMessageReactionDecreased struct { 95 | ID string `json:"id"` 96 | Count int64 `json:"count"` 97 | } 98 | 99 | type MessageMessageAnswered struct { 100 | ID string `json:"id"` 101 | } 102 | 103 | type MessageMessageCreated struct { 104 | ID string `json:"id"` 105 | Message string `json:"message"` 106 | } 107 | 108 | type Message struct { 109 | Kind string `json:"kind"` 110 | Value any `json:"value"` 111 | RoomID string `json:"-"` 112 | } 113 | 114 | func (h apiHandler) notifyClients(msg Message) { 115 | h.mu.Lock() 116 | defer h.mu.Unlock() 117 | 118 | subscribers, ok := h.subscribers[msg.RoomID] 119 | if !ok || len(subscribers) == 0 { 120 | return 121 | } 122 | 123 | for conn, cancel := range subscribers { 124 | if err := conn.WriteJSON(msg); err != nil { 125 | slog.Error("failed to send message to client", "error", err) 126 | cancel() 127 | } 128 | } 129 | } 130 | 131 | func (h apiHandler) handleSubscribe(w http.ResponseWriter, r *http.Request) { 132 | _, rawRoomID, _, ok := h.readRoom(w, r) 133 | if !ok { 134 | return 135 | } 136 | 137 | c, err := h.upgrader.Upgrade(w, r, nil) 138 | if err != nil { 139 | slog.Warn("failed to upgrade connection", "error", err) 140 | http.Error(w, "failed to upgrade to ws connection", http.StatusBadRequest) 141 | return 142 | } 143 | 144 | defer c.Close() 145 | 146 | ctx, cancel := context.WithCancel(r.Context()) 147 | 148 | h.mu.Lock() 149 | if _, ok := h.subscribers[rawRoomID]; !ok { 150 | h.subscribers[rawRoomID] = make(map[*websocket.Conn]context.CancelFunc) 151 | } 152 | slog.Info("new client connected", "room_id", rawRoomID, "client_ip", r.RemoteAddr) 153 | h.subscribers[rawRoomID][c] = cancel 154 | h.mu.Unlock() 155 | 156 | <-ctx.Done() 157 | 158 | h.mu.Lock() 159 | delete(h.subscribers[rawRoomID], c) 160 | h.mu.Unlock() 161 | } 162 | 163 | func (h apiHandler) handleCreateRoom(w http.ResponseWriter, r *http.Request) { 164 | type _body struct { 165 | Theme string `json:"theme"` 166 | } 167 | var body _body 168 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 169 | http.Error(w, "invalid json", http.StatusBadRequest) 170 | return 171 | } 172 | 173 | roomID, err := h.q.InsertRoom(r.Context(), body.Theme) 174 | if err != nil { 175 | slog.Error("failed to insert room", "error", err) 176 | http.Error(w, "something went wrong", http.StatusInternalServerError) 177 | return 178 | } 179 | 180 | type response struct { 181 | ID string `json:"id"` 182 | } 183 | 184 | sendJSON(w, response{ID: roomID.String()}) 185 | } 186 | 187 | func (h apiHandler) handleGetRooms(w http.ResponseWriter, r *http.Request) { 188 | rooms, err := h.q.GetRooms(r.Context()) 189 | if err != nil { 190 | http.Error(w, "something went wrong", http.StatusInternalServerError) 191 | slog.Error("failed to get rooms", "error", err) 192 | return 193 | } 194 | 195 | if rooms == nil { 196 | rooms = []pgstore.Room{} 197 | } 198 | 199 | sendJSON(w, rooms) 200 | } 201 | 202 | func (h apiHandler) handleGetRoom(w http.ResponseWriter, r *http.Request) { 203 | room, _, _, ok := h.readRoom(w, r) 204 | if !ok { 205 | return 206 | } 207 | 208 | sendJSON(w, room) 209 | } 210 | 211 | func (h apiHandler) handleCreateRoomMessage(w http.ResponseWriter, r *http.Request) { 212 | _, rawRoomID, roomID, ok := h.readRoom(w, r) 213 | if !ok { 214 | return 215 | } 216 | 217 | type _body struct { 218 | Message string `json:"message"` 219 | } 220 | var body _body 221 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 222 | http.Error(w, "invalid json", http.StatusBadRequest) 223 | return 224 | } 225 | 226 | messageID, err := h.q.InsertMessage(r.Context(), pgstore.InsertMessageParams{RoomID: roomID, Message: body.Message}) 227 | if err != nil { 228 | slog.Error("failed to insert message", "error", err) 229 | http.Error(w, "something went wrong", http.StatusInternalServerError) 230 | return 231 | } 232 | 233 | type response struct { 234 | ID string `json:"id"` 235 | } 236 | 237 | sendJSON(w, response{ID: messageID.String()}) 238 | 239 | go h.notifyClients(Message{ 240 | Kind: MessageKindMessageCreated, 241 | RoomID: rawRoomID, 242 | Value: MessageMessageCreated{ 243 | ID: messageID.String(), 244 | Message: body.Message, 245 | }, 246 | }) 247 | } 248 | 249 | func (h apiHandler) handleGetRoomMessages(w http.ResponseWriter, r *http.Request) { 250 | _, _, roomID, ok := h.readRoom(w, r) 251 | if !ok { 252 | return 253 | } 254 | 255 | messages, err := h.q.GetRoomMessages(r.Context(), roomID) 256 | if err != nil { 257 | http.Error(w, "something went wrong", http.StatusInternalServerError) 258 | slog.Error("failed to get room messages", "error", err) 259 | return 260 | } 261 | 262 | if messages == nil { 263 | messages = []pgstore.Message{} 264 | } 265 | 266 | sendJSON(w, messages) 267 | } 268 | 269 | func (h apiHandler) handleGetRoomMessage(w http.ResponseWriter, r *http.Request) { 270 | _, _, _, ok := h.readRoom(w, r) 271 | if !ok { 272 | return 273 | } 274 | 275 | rawMessageID := chi.URLParam(r, "message_id") 276 | messageID, err := uuid.Parse(rawMessageID) 277 | if err != nil { 278 | http.Error(w, "invalid message id", http.StatusBadRequest) 279 | return 280 | } 281 | 282 | messages, err := h.q.GetMessage(r.Context(), messageID) 283 | if err != nil { 284 | if errors.Is(err, pgx.ErrNoRows) { 285 | http.Error(w, "message not found", http.StatusBadRequest) 286 | return 287 | } 288 | 289 | slog.Error("failed to get message", "error", err) 290 | http.Error(w, "something went wrong", http.StatusInternalServerError) 291 | return 292 | } 293 | 294 | sendJSON(w, messages) 295 | } 296 | 297 | func (h apiHandler) handleReactToMessage(w http.ResponseWriter, r *http.Request) { 298 | _, rawRoomID, _, ok := h.readRoom(w, r) 299 | if !ok { 300 | return 301 | } 302 | 303 | rawID := chi.URLParam(r, "message_id") 304 | id, err := uuid.Parse(rawID) 305 | if err != nil { 306 | http.Error(w, "invalid message id", http.StatusBadRequest) 307 | return 308 | } 309 | 310 | count, err := h.q.ReactToMessage(r.Context(), id) 311 | if err != nil { 312 | http.Error(w, "something went wrong", http.StatusInternalServerError) 313 | slog.Error("failed to react to message", "error", err) 314 | return 315 | } 316 | 317 | type response struct { 318 | Count int64 `json:"count"` 319 | } 320 | 321 | sendJSON(w, response{Count: count}) 322 | 323 | go h.notifyClients(Message{ 324 | Kind: MessageKindMessageRactionIncreased, 325 | RoomID: rawRoomID, 326 | Value: MessageMessageReactionIncreased{ 327 | ID: rawID, 328 | Count: count, 329 | }, 330 | }) 331 | } 332 | 333 | func (h apiHandler) handleRemoveReactFromMessage(w http.ResponseWriter, r *http.Request) { 334 | _, rawRoomID, _, ok := h.readRoom(w, r) 335 | if !ok { 336 | return 337 | } 338 | 339 | rawID := chi.URLParam(r, "message_id") 340 | id, err := uuid.Parse(rawID) 341 | if err != nil { 342 | http.Error(w, "invalid message id", http.StatusBadRequest) 343 | return 344 | } 345 | 346 | count, err := h.q.RemoveReactionFromMessage(r.Context(), id) 347 | if err != nil { 348 | http.Error(w, "something went wrong", http.StatusInternalServerError) 349 | slog.Error("failed to react to message", "error", err) 350 | return 351 | } 352 | 353 | type response struct { 354 | Count int64 `json:"count"` 355 | } 356 | 357 | sendJSON(w, response{Count: count}) 358 | 359 | go h.notifyClients(Message{ 360 | Kind: MessageKindMessageRactionDecreased, 361 | RoomID: rawRoomID, 362 | Value: MessageMessageReactionDecreased{ 363 | ID: rawID, 364 | Count: count, 365 | }, 366 | }) 367 | } 368 | 369 | func (h apiHandler) handleMarkMessageAsAnswered(w http.ResponseWriter, r *http.Request) { 370 | _, rawRoomID, _, ok := h.readRoom(w, r) 371 | if !ok { 372 | return 373 | } 374 | 375 | rawID := chi.URLParam(r, "message_id") 376 | id, err := uuid.Parse(rawID) 377 | if err != nil { 378 | http.Error(w, "invalid message id", http.StatusBadRequest) 379 | return 380 | } 381 | 382 | err = h.q.MarkMessageAsAnswered(r.Context(), id) 383 | if err != nil { 384 | http.Error(w, "something went wrong", http.StatusInternalServerError) 385 | slog.Error("failed to react to message", "error", err) 386 | return 387 | } 388 | 389 | w.WriteHeader(http.StatusOK) 390 | 391 | go h.notifyClients(Message{ 392 | Kind: MessageKindMessageAnswered, 393 | RoomID: rawRoomID, 394 | Value: MessageMessageAnswered{ 395 | ID: rawID, 396 | }, 397 | }) 398 | } 399 | -------------------------------------------------------------------------------- /internal/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log/slog" 7 | "net/http" 8 | 9 | "github.com/rocketseat-education/semana-tech-go-react-server/internal/store/pgstore" 10 | 11 | "github.com/go-chi/chi/v5" 12 | "github.com/google/uuid" 13 | "github.com/jackc/pgx/v5" 14 | ) 15 | 16 | func (h apiHandler) readRoom( 17 | w http.ResponseWriter, 18 | r *http.Request, 19 | ) (room pgstore.Room, rawRoomID string, roomID uuid.UUID, ok bool) { 20 | rawRoomID = chi.URLParam(r, "room_id") 21 | roomID, err := uuid.Parse(rawRoomID) 22 | if err != nil { 23 | http.Error(w, "invalid room id", http.StatusBadRequest) 24 | return pgstore.Room{}, "", uuid.UUID{}, false 25 | } 26 | 27 | room, err = h.q.GetRoom(r.Context(), roomID) 28 | if err != nil { 29 | if errors.Is(err, pgx.ErrNoRows) { 30 | http.Error(w, "room not found", http.StatusBadRequest) 31 | return pgstore.Room{}, "", uuid.UUID{}, false 32 | } 33 | 34 | slog.Error("failed to get room", "error", err) 35 | http.Error(w, "something went wrong", http.StatusInternalServerError) 36 | return pgstore.Room{}, "", uuid.UUID{}, false 37 | } 38 | 39 | return room, rawRoomID, roomID, true 40 | } 41 | 42 | func sendJSON(w http.ResponseWriter, rawData any) { 43 | data, _ := json.Marshal(rawData) 44 | w.Header().Set("Content-Type", "application/json") 45 | _, _ = w.Write(data) 46 | } 47 | -------------------------------------------------------------------------------- /internal/store/pgstore/db.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | 5 | package pgstore 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/jackc/pgx/v5/pgconn" 12 | ) 13 | 14 | type DBTX interface { 15 | Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) 16 | Query(context.Context, string, ...interface{}) (pgx.Rows, error) 17 | QueryRow(context.Context, string, ...interface{}) pgx.Row 18 | } 19 | 20 | func New(db DBTX) *Queries { 21 | return &Queries{db: db} 22 | } 23 | 24 | type Queries struct { 25 | db DBTX 26 | } 27 | 28 | func (q *Queries) WithTx(tx pgx.Tx) *Queries { 29 | return &Queries{ 30 | db: tx, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/store/pgstore/migrations/001_create_rooms_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS rooms ( 2 | "id" uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 3 | "theme" VARCHAR(255) NOT NULL 4 | ); 5 | 6 | ---- create above / drop below ---- 7 | 8 | DROP TABLE IF EXISTS rooms; 9 | -------------------------------------------------------------------------------- /internal/store/pgstore/migrations/002_create_messages_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS messages ( 2 | "id" uuid PRIMARY KEY NOT NULL DEFAULT gen_random_uuid(), 3 | "room_id" uuid NOT NULL, 4 | "message" VARCHAR(255) NOT NULL, 5 | "reaction_count" BIGINT NOT NULL DEFAULT 0, 6 | "answered" BOOLEAN NOT NULL DEFAULT false, 7 | 8 | FOREIGN KEY (room_id) REFERENCES rooms(id) 9 | ); 10 | 11 | ---- create above / drop below ---- 12 | 13 | DROP TABLE IF EXISTS messages; 14 | -------------------------------------------------------------------------------- /internal/store/pgstore/migrations/tern.conf: -------------------------------------------------------------------------------- 1 | [database] 2 | port = {{ env "WSRS_DATABASE_PORT" }} 3 | database = {{ env "WSRS_DATABASE_NAME" }} 4 | user = {{ env "WSRS_DATABASE_USER" }} 5 | password = {{ env "WSRS_DATABASE_PASSWORD" }} 6 | host = {{ env "WSRS_DATABASE_HOST" }} 7 | -------------------------------------------------------------------------------- /internal/store/pgstore/models.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | 5 | package pgstore 6 | 7 | import ( 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type Message struct { 12 | ID uuid.UUID `db:"id" json:"id"` 13 | RoomID uuid.UUID `db:"room_id" json:"room_id"` 14 | Message string `db:"message" json:"message"` 15 | ReactionCount int64 `db:"reaction_count" json:"reaction_count"` 16 | Answered bool `db:"answered" json:"answered"` 17 | } 18 | 19 | type Room struct { 20 | ID uuid.UUID `db:"id" json:"id"` 21 | Theme string `db:"theme" json:"theme"` 22 | } 23 | -------------------------------------------------------------------------------- /internal/store/pgstore/queries.sql.go: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.26.0 4 | // source: queries.sql 5 | 6 | package pgstore 7 | 8 | import ( 9 | "context" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | const getMessage = `-- name: GetMessage :one 15 | SELECT 16 | "id", "room_id", "message", "reaction_count", "answered" 17 | FROM messages 18 | WHERE 19 | id = $1 20 | ` 21 | 22 | func (q *Queries) GetMessage(ctx context.Context, id uuid.UUID) (Message, error) { 23 | row := q.db.QueryRow(ctx, getMessage, id) 24 | var i Message 25 | err := row.Scan( 26 | &i.ID, 27 | &i.RoomID, 28 | &i.Message, 29 | &i.ReactionCount, 30 | &i.Answered, 31 | ) 32 | return i, err 33 | } 34 | 35 | const getRoom = `-- name: GetRoom :one 36 | SELECT 37 | "id", "theme" 38 | FROM rooms 39 | WHERE id = $1 40 | ` 41 | 42 | func (q *Queries) GetRoom(ctx context.Context, id uuid.UUID) (Room, error) { 43 | row := q.db.QueryRow(ctx, getRoom, id) 44 | var i Room 45 | err := row.Scan(&i.ID, &i.Theme) 46 | return i, err 47 | } 48 | 49 | const getRoomMessages = `-- name: GetRoomMessages :many 50 | SELECT 51 | "id", "room_id", "message", "reaction_count", "answered" 52 | FROM messages 53 | WHERE 54 | room_id = $1 55 | ` 56 | 57 | func (q *Queries) GetRoomMessages(ctx context.Context, roomID uuid.UUID) ([]Message, error) { 58 | rows, err := q.db.Query(ctx, getRoomMessages, roomID) 59 | if err != nil { 60 | return nil, err 61 | } 62 | defer rows.Close() 63 | var items []Message 64 | for rows.Next() { 65 | var i Message 66 | if err := rows.Scan( 67 | &i.ID, 68 | &i.RoomID, 69 | &i.Message, 70 | &i.ReactionCount, 71 | &i.Answered, 72 | ); err != nil { 73 | return nil, err 74 | } 75 | items = append(items, i) 76 | } 77 | if err := rows.Err(); err != nil { 78 | return nil, err 79 | } 80 | return items, nil 81 | } 82 | 83 | const getRooms = `-- name: GetRooms :many 84 | SELECT 85 | "id", "theme" 86 | FROM rooms 87 | ` 88 | 89 | func (q *Queries) GetRooms(ctx context.Context) ([]Room, error) { 90 | rows, err := q.db.Query(ctx, getRooms) 91 | if err != nil { 92 | return nil, err 93 | } 94 | defer rows.Close() 95 | var items []Room 96 | for rows.Next() { 97 | var i Room 98 | if err := rows.Scan(&i.ID, &i.Theme); err != nil { 99 | return nil, err 100 | } 101 | items = append(items, i) 102 | } 103 | if err := rows.Err(); err != nil { 104 | return nil, err 105 | } 106 | return items, nil 107 | } 108 | 109 | const insertMessage = `-- name: InsertMessage :one 110 | INSERT INTO messages 111 | ( "room_id", "message" ) VALUES 112 | ( $1, $2 ) 113 | RETURNING "id" 114 | ` 115 | 116 | type InsertMessageParams struct { 117 | RoomID uuid.UUID `db:"room_id" json:"room_id"` 118 | Message string `db:"message" json:"message"` 119 | } 120 | 121 | func (q *Queries) InsertMessage(ctx context.Context, arg InsertMessageParams) (uuid.UUID, error) { 122 | row := q.db.QueryRow(ctx, insertMessage, arg.RoomID, arg.Message) 123 | var id uuid.UUID 124 | err := row.Scan(&id) 125 | return id, err 126 | } 127 | 128 | const insertRoom = `-- name: InsertRoom :one 129 | INSERT INTO rooms 130 | ( "theme" ) VALUES 131 | ( $1 ) 132 | RETURNING "id" 133 | ` 134 | 135 | func (q *Queries) InsertRoom(ctx context.Context, theme string) (uuid.UUID, error) { 136 | row := q.db.QueryRow(ctx, insertRoom, theme) 137 | var id uuid.UUID 138 | err := row.Scan(&id) 139 | return id, err 140 | } 141 | 142 | const markMessageAsAnswered = `-- name: MarkMessageAsAnswered :exec 143 | UPDATE messages 144 | SET 145 | answered = true 146 | WHERE 147 | id = $1 148 | ` 149 | 150 | func (q *Queries) MarkMessageAsAnswered(ctx context.Context, id uuid.UUID) error { 151 | _, err := q.db.Exec(ctx, markMessageAsAnswered, id) 152 | return err 153 | } 154 | 155 | const reactToMessage = `-- name: ReactToMessage :one 156 | UPDATE messages 157 | SET 158 | reaction_count = reaction_count + 1 159 | WHERE 160 | id = $1 161 | RETURNING reaction_count 162 | ` 163 | 164 | func (q *Queries) ReactToMessage(ctx context.Context, id uuid.UUID) (int64, error) { 165 | row := q.db.QueryRow(ctx, reactToMessage, id) 166 | var reaction_count int64 167 | err := row.Scan(&reaction_count) 168 | return reaction_count, err 169 | } 170 | 171 | const removeReactionFromMessage = `-- name: RemoveReactionFromMessage :one 172 | UPDATE messages 173 | SET 174 | reaction_count = reaction_count - 1 175 | WHERE 176 | id = $1 177 | RETURNING reaction_count 178 | ` 179 | 180 | func (q *Queries) RemoveReactionFromMessage(ctx context.Context, id uuid.UUID) (int64, error) { 181 | row := q.db.QueryRow(ctx, removeReactionFromMessage, id) 182 | var reaction_count int64 183 | err := row.Scan(&reaction_count) 184 | return reaction_count, err 185 | } 186 | -------------------------------------------------------------------------------- /internal/store/pgstore/queries/queries.sql: -------------------------------------------------------------------------------- 1 | -- name: GetRoom :one 2 | SELECT 3 | "id", "theme" 4 | FROM rooms 5 | WHERE id = $1; 6 | 7 | -- name: GetRooms :many 8 | SELECT 9 | "id", "theme" 10 | FROM rooms; 11 | 12 | -- name: InsertRoom :one 13 | INSERT INTO rooms 14 | ( "theme" ) VALUES 15 | ( $1 ) 16 | RETURNING "id"; 17 | 18 | -- name: GetMessage :one 19 | SELECT 20 | "id", "room_id", "message", "reaction_count", "answered" 21 | FROM messages 22 | WHERE 23 | id = $1; 24 | 25 | -- name: GetRoomMessages :many 26 | SELECT 27 | "id", "room_id", "message", "reaction_count", "answered" 28 | FROM messages 29 | WHERE 30 | room_id = $1; 31 | 32 | -- name: InsertMessage :one 33 | INSERT INTO messages 34 | ( "room_id", "message" ) VALUES 35 | ( $1, $2 ) 36 | RETURNING "id"; 37 | 38 | -- name: ReactToMessage :one 39 | UPDATE messages 40 | SET 41 | reaction_count = reaction_count + 1 42 | WHERE 43 | id = $1 44 | RETURNING reaction_count; 45 | 46 | -- name: RemoveReactionFromMessage :one 47 | UPDATE messages 48 | SET 49 | reaction_count = reaction_count - 1 50 | WHERE 51 | id = $1 52 | RETURNING reaction_count; 53 | 54 | -- name: MarkMessageAsAnswered :exec 55 | UPDATE messages 56 | SET 57 | answered = true 58 | WHERE 59 | id = $1; 60 | -------------------------------------------------------------------------------- /internal/store/pgstore/sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | sql: 3 | - engine: "postgresql" 4 | queries: "./queries" 5 | schema: "./migrations" 6 | gen: 7 | go: 8 | out: "." 9 | package: "pgstore" 10 | sql_package: "pgx/v5" 11 | emit_json_tags: true 12 | emit_db_tags: true 13 | overrides: 14 | - db_type: "uuid" 15 | go_type: 16 | import: "github.com/google/uuid" 17 | type: "UUID" 18 | --------------------------------------------------------------------------------