From 3067beb96c8287a00132e53286ab5097a354458b Mon Sep 17 00:00:00 2001 From: Miguel de la Cruz Date: Sat, 25 Sep 2021 17:23:56 +0200 Subject: [PATCH] Add create post endpoint and check target to the makefile --- Makefile | 4 ++ server/api/post.go | 48 +++++++++++++++++++ server/app/permissions.go | 6 +++ server/app/post.go | 15 ++++++ server/model/post.go | 24 ++++++++++ server/services/store/post.go | 88 +++++++++++++++++++++++++++++++++- server/services/store/store.go | 4 ++ server/web/web.go | 1 + webapp/src/client.js | 14 ++++++ webapp/src/index.js | 11 ++++- webapp/src/pages/chat.js | 64 +++++++++++++++++++++++++ webapp/src/pages/game.js | 6 ++- 12 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 server/api/post.go create mode 100644 server/app/permissions.go create mode 100644 server/app/post.go create mode 100644 webapp/src/pages/chat.js diff --git a/Makefile b/Makefile index 64112c6..bf3f0c1 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,10 @@ mod: cd server && go mod vendor cd server && go mod tidy +.PHONY: check +check: + cd server && golangci-lint run ./... + .PHONY: test test: cd server && go test -v -race -count 1 ./... diff --git a/server/api/post.go b/server/api/post.go new file mode 100644 index 0000000..ab80114 --- /dev/null +++ b/server/api/post.go @@ -0,0 +1,48 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "git.ctrlz.es/mgdelacroix/craban/model" + "git.ctrlz.es/mgdelacroix/craban/utils" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" +) + +func (a *API) CreatePostForGame(w http.ResponseWriter, r *http.Request) { + gameID, _ := strconv.Atoi(mux.Vars(r)["id"]) + user, _ := UserFromRequest(r) + + body := ParseBody(r) + message := body.String("message") + + if message == "" { + http.Error(w, fmt.Sprintf("%s: message should not be empty", http.StatusText(http.StatusBadRequest)), http.StatusBadRequest) + return + } + + if !a.App.HasPermissionToGame(user.ID, gameID) { + http.Error(w, fmt.Sprintf("%s: user cannot post in this game", http.StatusText(http.StatusForbidden)), http.StatusForbidden) + return + } + + post := &model.Post{ + UserID: user.ID, + GameID: gameID, + CreatedAt: utils.Millis(time.Now()), + Body: message, + } + + newPost, err := a.App.CreatePost(post) + if err != nil { + log.Error().Err(err).Msg("post couldn't be saved") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + JSON(w, newPost, 201) +} diff --git a/server/app/permissions.go b/server/app/permissions.go new file mode 100644 index 0000000..6be493b --- /dev/null +++ b/server/app/permissions.go @@ -0,0 +1,6 @@ +package app + +// ToDo: implement +func (a *App) HasPermissionToGame(userID, gameID int) bool { + return true +} diff --git a/server/app/post.go b/server/app/post.go new file mode 100644 index 0000000..01bbd9b --- /dev/null +++ b/server/app/post.go @@ -0,0 +1,15 @@ +package app + +import ( + "fmt" + + "git.ctrlz.es/mgdelacroix/craban/model" +) + +func (a *App) CreatePost(post *model.Post) (*model.Post, error) { + if err := post.IsValid(); err != nil { + return nil, fmt.Errorf("invalid post for creation: %w", err) + } + + return a.Store.Post().Create(post) +} diff --git a/server/model/post.go b/server/model/post.go index 1c0277e..793c676 100644 --- a/server/model/post.go +++ b/server/model/post.go @@ -1,5 +1,9 @@ package model +import ( + "fmt" +) + type Post struct { ID int `json:"id"` UserID int `json:"user_id"` @@ -7,3 +11,23 @@ type Post struct { CreatedAt int `json:"createdat"` Body string `json:"body"` } + +func (p *Post) IsValid() error { + if p.UserID == 0 { + return fmt.Errorf("user id must not be empty") + } + + if p.GameID == 0 { + return fmt.Errorf("game id must not be empty") + } + + if p.CreatedAt == 0 { + return fmt.Errorf("created at must not be empty") + } + + if p.Body == "" { + return fmt.Errorf("body must not be empty") + } + + return nil +} diff --git a/server/services/store/post.go b/server/services/store/post.go index 31d7754..6c9d0d9 100644 --- a/server/services/store/post.go +++ b/server/services/store/post.go @@ -2,9 +2,14 @@ package store import ( "database/sql" + "fmt" + + sq "github.com/Masterminds/squirrel" + + "git.ctrlz.es/mgdelacroix/craban/model" ) -var postColums = []string{"id", "user_id", "game_id", "createdat", "body"} +var postColumns = []string{"id", "user_id", "game_id", "createdat", "body"} type PostStore struct { Conn *sql.DB @@ -13,3 +18,84 @@ type PostStore struct { func NewPostStore(s *Store) *PostStore { return &PostStore{Conn: s.Conn} } + +func (ps *PostStore) Q() sq.StatementBuilderType { + return sq.StatementBuilder.RunWith(ps.Conn) +} + +func (ps *PostStore) postsFromRows(rows *sql.Rows) ([]*model.Post, error) { + posts := []*model.Post{} + + for rows.Next() { + var post model.Post + + err := rows.Scan( + &post.ID, + &post.UserID, + &post.GameID, + &post.CreatedAt, + &post.Body, + ) + if err != nil { + return nil, err + } + + posts = append(posts, &post) + } + + return posts, nil +} + +func (ps *PostStore) GetByID(id int) (*model.Post, error) { + return ps.getPostByCondition(sq.Eq{"id": id}) +} + +func (ps *PostStore) getPostByCondition(condition sq.Eq) (*model.Post, error) { + posts, err := ps.getPostsByCondition(condition) + if err != nil { + return nil, err + } + + return posts[0], nil +} + +func (ps *PostStore) getPostsByCondition(condition sq.Eq) ([]*model.Post, error) { + rows, err := ps.Q().Select(postColumns...). + From("posts"). + Where(condition). + Query() + if err != nil { + return nil, fmt.Errorf("cannot get posts: %w", err) + } + defer rows.Close() + + posts, err := ps.postsFromRows(rows) + if err != nil { + return nil, fmt.Errorf("cannot get posts from rows: %w", err) + } + + if len(posts) == 0 { + return nil, sql.ErrNoRows + } + + return posts, nil +} + +func (ps *PostStore) Create(post *model.Post) (*model.Post, error) { + query := ps.Q().Insert("posts"). + Columns(postColumns[1:]...). + Values(post.UserID, post.GameID, post.CreatedAt, post.Body) + + res, err := query.Exec() + if err != nil { + return nil, fmt.Errorf("cannot insert post: %w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("cannot get last insert id for created post: %w", err) + } + + return ps.GetByID(int(id)) + +} diff --git a/server/services/store/store.go b/server/services/store/store.go index 1ad11ca..70f03b6 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -58,6 +58,10 @@ func (s *Store) Game() *GameStore { return s.gameStore } +func (s *Store) Post() *PostStore { + return s.postStore +} + func (s *Store) EnsureSchema() error { schema := ` CREATE TABLE IF NOT EXISTS users ( diff --git a/server/web/web.go b/server/web/web.go index 52f6d7f..16a7854 100644 --- a/server/web/web.go +++ b/server/web/web.go @@ -42,6 +42,7 @@ func (w *WebServer) RegisterRoutes(api *api.API) { apiRouter.HandleFunc("/games", api.Secured(api.CreateGame)).Methods("POST") apiRouter.HandleFunc("/games", api.Secured(api.ListGames)).Methods("GET") apiRouter.HandleFunc("/games/{id:[0-9]+}", api.Secured(api.GetGame)).Methods("GET") + apiRouter.HandleFunc("/games/{id:[0-9]+}/post", api.Secured(api.CreatePostForGame)).Methods("POST") staticFSSub, _ := fs.Sub(staticFS, "static") staticFSHandler := StaticFSHandler{http.FileServer(http.FS(staticFSSub))} diff --git a/webapp/src/client.js b/webapp/src/client.js index 1945233..b43529e 100644 --- a/webapp/src/client.js +++ b/webapp/src/client.js @@ -53,6 +53,20 @@ export class Client { throw new Error(`${r.status} invalid response`) }) } + + createPostForGame(gameId, message) { + return fetch(`/api/games/${gameId}/post`, { + method: 'POST', + headers: tokenHeaders(), + body: JSON.stringify({ message }) + }).then(r => { + if (r.status === 201) { + return r.json() + } + log.error(`Got invalid response when trying to create a post for game ${id}`) + throw new Error(`${r.status} invalid response`) + }) + } } const client = new Client() diff --git a/webapp/src/index.js b/webapp/src/index.js index 7d31ab9..523111a 100644 --- a/webapp/src/index.js +++ b/webapp/src/index.js @@ -14,6 +14,7 @@ import { TokenContext } from './context' import Login from './pages/login' import Home from './pages/home' import Game from './pages/game' +import Chat from './pages/chat' const Secure = ({children}) => { @@ -33,11 +34,17 @@ const App = () => { - + - + + + + + + + diff --git a/webapp/src/pages/chat.js b/webapp/src/pages/chat.js new file mode 100644 index 0000000..6f5693e --- /dev/null +++ b/webapp/src/pages/chat.js @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react' +import { useParams } from 'react-router-dom' +import { + Box, + TextField, + Button +} from '@material-ui/core'; + +import client from '../client' + + +const Chat = () => { + const [posts, setPosts] = useState([]) + const [game, setGame] = useState({}) + const [message, setMessage] = useState("") + const { gameId } = useParams() + + useEffect(() => { + client.getGame(gameId).then(game => setGame(game)) + }, [gameId]) + + const handleChange = (e) => { + setMessage(e.target.value) + } + + const handleSubmit = (e) => { + e.preventDefault() + + client + .createPostForGameId(gameId, message) + .then(r => { + log.debug(`Message posted`) + }) + + // maybe disable sending and show a spinner instead + setMessage("") + } + + return ( +
+

{game.name}

+

Chat

+ +
+ + +
+
+ ) +} + +export default Chat diff --git a/webapp/src/pages/game.js b/webapp/src/pages/game.js index c3c9c53..e93a67a 100644 --- a/webapp/src/pages/game.js +++ b/webapp/src/pages/game.js @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { useParams } from 'react-router-dom' +import { useParams, Link } from 'react-router-dom' import client from '../client' @@ -13,8 +13,10 @@ const Game = () => { }, [gameId]) return ( -
+

{game.name}

+ +

Game chat

) }