diff --git a/.env b/.env index 7e43ac9..e45c7b4 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -DATABASE_URL='postgresql://localhost?sslmode=disable' +DATABASE_URL='postgresql://localhost/cerealnotes?sslmode=disable' PORT=8080 TOKEN_SIGNING_KEY='AllYourBase' diff --git a/README.md b/README.md index fe1ec89..7bff1d2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ # Installation ## Locally -* postgres server installed and running - * `brew install postgres` - * `pg_ctl -D /usr/local/var/postgres start` +* postgres server installed and running: please refer to `migrations/README.md` for more info * heroku cli installed * `brew install heroku` * golang installed @@ -20,7 +18,4 @@ Assuming your local environment is setup correctly with Golang standards, you ca 1. `cd to this repo` 2. `go install && heroku local` -3. Visit `localhost:8080/login-or-signup` - -# Run DB migrations -More db information in `migrations/README.md` +3. Visit `localhost:8080/` diff --git a/databaseutil/databaseutil.go b/databaseutil/databaseutil.go deleted file mode 100644 index bae1fb9..0000000 --- a/databaseutil/databaseutil.go +++ /dev/null @@ -1,138 +0,0 @@ -/* -Package databaseutil abstracts away details about sql and postgres. - -These functions only accept and return primitive types. -*/ -package databaseutil - -import ( - "database/sql" - "errors" - "time" - - "github.com/lib/pq" -) - -var db *sql.DB - -// UniqueConstraintError is returned when a uniqueness constraint is violated during an insert. -var UniqueConstraintError = errors.New("postgres: unique constraint violation") - -// QueryResultContainedMultipleRowsError is returned when a query unexpectedly returns more than one row. -var QueryResultContainedMultipleRowsError = errors.New("query result unexpectedly contained multiple rows") - -// QueryResultContainedNoRowsError is returned when a query unexpectedly returns no rows. -var QueryResultContainedNoRowsError = errors.New("query result unexpectedly contained no rows") - -// ConnectToDatabase also pings the database to ensure a working connection. -func ConnectToDatabase(databaseUrl string) error { - { - tempDb, err := sql.Open("postgres", databaseUrl) - if err != nil { - return err - } - - db = tempDb - } - - if err := db.Ping(); err != nil { - return err - } - - return nil -} - -func InsertIntoUserTable( - displayName string, - emailAddress string, - password []byte, - creationTime time.Time, -) error { - sqlQuery := ` - INSERT INTO app_user (display_name, email_address, password, creation_time) - VALUES ($1, $2, $3, $4)` - - rows, err := db.Query(sqlQuery, displayName, emailAddress, password, creationTime) - if err != nil { - return convertPostgresError(err) - } - defer rows.Close() - - if err := rows.Err(); err != nil { - return convertPostgresError(err) - } - - return nil -} - -func GetPasswordForUserWithEmailAddress(emailAddress string) ([]byte, error) { - sqlQuery := ` - SELECT password FROM app_user - WHERE email_address = $1` - - rows, err := db.Query(sqlQuery, emailAddress) - if err != nil { - return nil, convertPostgresError(err) - } - defer rows.Close() - - var password []byte - for rows.Next() { - if password != nil { - return nil, QueryResultContainedMultipleRowsError - } - - if err := rows.Scan(&password); err != nil { - return nil, err - } - } - - if password == nil { - return nil, QueryResultContainedNoRowsError - } - - return password, nil -} - -func GetIdForUserWithEmailAddress(emailAddress string) (int64, error) { - sqlQuery := ` - SELECT id FROM app_user - WHERE email_address = $1` - - rows, err := db.Query(sqlQuery, emailAddress) - if err != nil { - return 0, convertPostgresError(err) - } - defer rows.Close() - - var userId int64 - for rows.Next() { - if userId != 0 { - return 0, QueryResultContainedMultipleRowsError - } - - if err := rows.Scan(&userId); err != nil { - return 0, err - } - } - - if userId == 0 { - return 0, QueryResultContainedNoRowsError - } - - return userId, nil -} - -// PRIVATE - -func convertPostgresError(err error) error { - const uniqueConstraintErrorCode = "23505" - - if postgresErr, ok := err.(*pq.Error); ok { - if postgresErr.Code == uniqueConstraintErrorCode { - return UniqueConstraintError - } - } - - return err -} diff --git a/handlers/handlers.go b/handlers/handlers.go index 8388d66..d0d31ad 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -5,12 +5,12 @@ import ( "fmt" "html/template" "net/http" + "strconv" "strings" "time" "github.com/atmiguel/cerealnotes/models" "github.com/atmiguel/cerealnotes/paths" - "github.com/atmiguel/cerealnotes/services/userservice" "github.com/dgrijalva/jwt-go" ) @@ -26,10 +26,15 @@ type JwtTokenClaim struct { jwt.StandardClaims } -var tokenSigningKey []byte +type Environment struct { + Db models.Datastore + TokenSigningKey []byte +} -func SetTokenSigningKey(key []byte) { - tokenSigningKey = key +func WrapUnauthenticatedEndpoint(env *Environment, handler UnauthenticatedEndpointHandlerType) http.HandlerFunc { + return func(responseWriter http.ResponseWriter, request *http.Request) { + handler(env, responseWriter, request) + } } // UNAUTHENTICATED HANDLERS @@ -37,12 +42,13 @@ func SetTokenSigningKey(key []byte) { // HandleLoginOrSignupPageRequest responds to unauthenticated GET requests with the login or signup page. // For authenticated requests, it redirects to the home page. func HandleLoginOrSignupPageRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, ) { switch request.Method { case http.MethodGet: - if _, err := getUserIdFromJwtToken(request); err == nil { + if _, err := getUserIdFromJwtToken(env, request); err == nil { http.Redirect( responseWriter, request, @@ -65,6 +71,7 @@ func HandleLoginOrSignupPageRequest( } func HandleUserApiRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, ) { @@ -84,12 +91,12 @@ func HandleUserApiRequest( } var statusCode int - if err := userservice.StoreNewUser( + if err := env.Db.StoreNewUser( signupForm.DisplayName, models.NewEmailAddress(signupForm.EmailAddress), signupForm.Password, ); err != nil { - if err == userservice.EmailAddressAlreadyInUseError { + if err == models.EmailAddressAlreadyInUseError { statusCode = http.StatusConflict } else { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) @@ -103,20 +110,18 @@ func HandleUserApiRequest( case http.MethodGet: - if _, err := getUserIdFromJwtToken(request); err != nil { + if _, err := getUserIdFromJwtToken(env, request); err != nil { http.Error(responseWriter, err.Error(), http.StatusUnauthorized) return } - user1 := models.User{"Adrian"} - user2 := models.User{"Evan"} - - usersById := map[models.UserId]models.User{ - 1: user1, - 2: user2, + usersById, err := env.Db.GetAllUsersById() + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return } - usersByIdJson, err := json.Marshal(usersById) + usersByIdJson, err := usersById.ToJson() if err != nil { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) return @@ -134,6 +139,7 @@ func HandleUserApiRequest( // HandleSessionApiRequest responds to POST requests by authenticating and responding with a JWT. // It responds to DELETE requests by expiring the client's cookie. func HandleSessionApiRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, ) { @@ -151,12 +157,12 @@ func HandleSessionApiRequest( return } - if err := userservice.AuthenticateUserCredentials( + if err := env.Db.AuthenticateUserCredentials( models.NewEmailAddress(loginForm.EmailAddress), loginForm.Password, ); err != nil { statusCode := http.StatusInternalServerError - if err == userservice.CredentialsNotAuthorizedError { + if err == models.CredentialsNotAuthorizedError { statusCode = http.StatusUnauthorized } http.Error(responseWriter, err.Error(), statusCode) @@ -165,13 +171,13 @@ func HandleSessionApiRequest( // Set our cookie to have a valid JWT Token as the value { - userId, err := userservice.GetIdForUserWithEmailAddress(models.NewEmailAddress(loginForm.EmailAddress)) + userId, err := env.Db.GetIdForUserWithEmailAddress(models.NewEmailAddress(loginForm.EmailAddress)) if err != nil { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) return } - token, err := createTokenAsString(userId, credentialTimeoutDuration) + token, err := CreateTokenAsString(env, userId, credentialTimeoutDuration) if err != nil { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) return @@ -214,28 +220,63 @@ func HandleSessionApiRequest( } } +func HandlePublicationApiRequest( + env *Environment, + responseWriter http.ResponseWriter, + request *http.Request, + userId models.UserId, +) { + switch request.Method { + case http.MethodPost: + if err := env.Db.PublishNotes(userId); err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + responseWriter.WriteHeader(http.StatusCreated) + + default: + respondWithMethodNotAllowed(responseWriter, http.MethodPost) + } +} + func HandleNoteApiRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, userId models.UserId, ) { switch request.Method { case http.MethodGet: - note1 := &models.Note{ - AuthorId: 1, - Content: "This is an example note.", - CreationTime: time.Now().Add(-oneWeek).UTC(), + + publishedNotes, err := env.Db.GetAllPublishedNotesVisibleBy(userId) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return } - note2 := &models.Note{ - AuthorId: 2, - Content: "What is this site for?", - CreationTime: time.Now().Add(-60 * 12).UTC(), + myUnpublishedNotes, err := env.Db.GetMyUnpublishedNotes(userId) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return } - notes := [2]*models.Note{note1, note2} + fmt.Println("number of published notes") + fmt.Println(len(publishedNotes)) + fmt.Println("number of unpublished notes") + fmt.Println(len(myUnpublishedNotes)) + + allNotes := myUnpublishedNotes + + // TODO figure out how to surface the publication number + + // for publicationNumber, noteMap := range publishedNotes { + for _, noteMap := range publishedNotes { + for id, note := range noteMap { + allNotes[id] = note + } + } - notesInJson, err := json.Marshal(notes) + notesInJson, err := allNotes.ToJson() if err != nil { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) return @@ -246,29 +287,175 @@ func HandleNoteApiRequest( fmt.Fprint(responseWriter, string(notesInJson)) + case http.MethodPost: + type NoteForm struct { + Content string `json:"content"` + } + + noteForm := new(NoteForm) + + if err := json.NewDecoder(request.Body).Decode(noteForm); err != nil { + http.Error(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + if len(strings.TrimSpace(noteForm.Content)) == 0 { + http.Error(responseWriter, "Note content cannot be empty or just whitespace", http.StatusBadRequest) + return + } + + note := &models.Note{ + AuthorId: models.UserId(userId), + Content: noteForm.Content, + CreationTime: time.Now().UTC(), + } + + noteId, err := env.Db.StoreNewNote(note) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + type NoteResponse struct { + NoteId int64 `json:"noteId"` + } + + noteString, err := json.Marshal(&NoteResponse{NoteId: int64(noteId)}) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + responseWriter.Header().Set("Content-Type", "application/json") + responseWriter.WriteHeader(http.StatusCreated) + + fmt.Fprint(responseWriter, string(noteString)) + + case http.MethodDelete: + + id, err := strconv.ParseInt(request.URL.Query().Get("id"), 10, 64) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + noteId := models.NoteId(id) + + noteMap, err := env.Db.GetUsersNotes(userId) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + if _, ok := noteMap[noteId]; !ok { + errorString := "No note with that Id written by you was found" + http.Error(responseWriter, errorString, http.StatusBadRequest) + return + } + + err = env.Db.DeleteNoteById(noteId) + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + responseWriter.WriteHeader(http.StatusOK) + default: - respondWithMethodNotAllowed(responseWriter, http.MethodGet) + respondWithMethodNotAllowed(responseWriter, http.MethodGet, http.MethodPost, http.MethodDelete) } } -type AuthentictedRequestHandlerType func( +func HandleCategoryApiRequest( + env *Environment, + responseWriter http.ResponseWriter, + request *http.Request, + userId models.UserId, +) { + switch request.Method { + case http.MethodPost: + + type CategoryForm struct { + NoteId int64 `json:"noteId"` + Category string `json:"category"` + } + + noteForm := new(CategoryForm) + + if err := json.NewDecoder(request.Body).Decode(noteForm); err != nil { + http.Error(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + category, err := models.DeserializeCategory(strings.ToLower(noteForm.Category)) + + if err != nil { + http.Error(responseWriter, err.Error(), http.StatusBadRequest) + return + } + + if err := env.Db.StoreNewNoteCategoryRelationship(models.NoteId(noteForm.NoteId), category); err != nil { + http.Error(responseWriter, err.Error(), http.StatusInternalServerError) + return + } + + responseWriter.WriteHeader(http.StatusCreated) + + default: + respondWithMethodNotAllowed(responseWriter, http.MethodPost) + } + +} + +type AuthenticatedRequestHandlerType func( + *Environment, http.ResponseWriter, *http.Request, - models.UserId) + models.UserId, +) -func AuthenticateOrRedirectToLogin( - authenticatedHandlerFunc AuthentictedRequestHandlerType, +type UnauthenticatedEndpointHandlerType func( + *Environment, + http.ResponseWriter, + *http.Request, +) + +func AuthenticateOrRedirect( + env *Environment, + authenticatedHandlerFunc AuthenticatedRequestHandlerType, + redirectPath string, ) http.HandlerFunc { return func(responseWriter http.ResponseWriter, request *http.Request) { - if userId, err := getUserIdFromJwtToken(request); err != nil { + if userId, err := getUserIdFromJwtToken(env, request); err != nil { + switch request.Method { // If not logged in, redirect to login page - http.Redirect( - responseWriter, - request, - paths.LoginOrSignupPage, - http.StatusTemporaryRedirect) + case http.MethodGet: + http.Redirect( + responseWriter, + request, + redirectPath, + http.StatusTemporaryRedirect) + return + default: + respondWithMethodNotAllowed(responseWriter, http.MethodGet) + } + } else { + authenticatedHandlerFunc(env, responseWriter, request, userId) + } + } +} + +func AuthenticateOrReturnUnauthorized( + env *Environment, + authenticatedHandlerFunc AuthenticatedRequestHandlerType, +) http.HandlerFunc { + return func(responseWriter http.ResponseWriter, request *http.Request) { + + if userId, err := getUserIdFromJwtToken(env, request); err != nil { + responseWriter.Header().Set("WWW-Authenticate", `Bearer realm="`+request.URL.Path+`"`) + http.Error(responseWriter, err.Error(), http.StatusUnauthorized) } else { - authenticatedHandlerFunc(responseWriter, request, userId) + authenticatedHandlerFunc(env, responseWriter, request, userId) } } } @@ -294,6 +481,7 @@ func RedirectToPathHandler( // AUTHENTICATED HANDLERS func HandleHomePageRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, userId models.UserId, @@ -313,6 +501,7 @@ func HandleHomePageRequest( } func HandleNotesPageRequest( + env *Environment, responseWriter http.ResponseWriter, request *http.Request, userId models.UserId, diff --git a/handlers/tokenutil.go b/handlers/tokenutil.go index 7d379c5..b4f2f6b 100644 --- a/handlers/tokenutil.go +++ b/handlers/tokenutil.go @@ -2,8 +2,6 @@ package handlers import ( "errors" - "fmt" - "log" "net/http" "strings" "time" @@ -14,16 +12,17 @@ import ( var InvalidJWTokenError = errors.New("Token was invalid or unreadable") -func parseTokenFromString(tokenAsString string) (*jwt.Token, error) { +func ParseTokenFromString(env *Environment, tokenAsString string) (*jwt.Token, error) { return jwt.ParseWithClaims( strings.TrimSpace(tokenAsString), &JwtTokenClaim{}, func(*jwt.Token) (interface{}, error) { - return tokenSigningKey, nil + return env.TokenSigningKey, nil }) } -func createTokenAsString( +func CreateTokenAsString( + env *Environment, userId models.UserId, durationTilExpiration time.Duration, ) (string, error) { @@ -36,16 +35,16 @@ func createTokenAsString( } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(tokenSigningKey) + return token.SignedString(env.TokenSigningKey) } -func getUserIdFromJwtToken(request *http.Request) (models.UserId, error) { +func getUserIdFromJwtToken(env *Environment, request *http.Request) (models.UserId, error) { cookie, err := request.Cookie(cerealNotesCookieName) if err != nil { return 0, err } - token, err := parseTokenFromString(cookie.Value) + token, err := ParseTokenFromString(env, cookie.Value) if err != nil { return 0, err } @@ -56,27 +55,3 @@ func getUserIdFromJwtToken(request *http.Request) (models.UserId, error) { return 0, InvalidJWTokenError } - -func tokenTest1() { - var num models.UserId = 32 - bob, err := createTokenAsString(num, 1) - if err != nil { - fmt.Println("create error") - log.Fatal(err) - } - - token, err := parseTokenFromString(bob) - if err != nil { - fmt.Println("parse error") - log.Fatal(err) - } - fmt.Println(bob) - if claims, ok := token.Claims.(*JwtTokenClaim); ok && token.Valid { - if claims.UserId != 32 { - log.Fatal("error in token") - } - fmt.Printf("%v %v", claims.UserId, claims.StandardClaims.ExpiresAt) - } else { - fmt.Println("Token claims could not be read") - } -} diff --git a/handlers/tokenutil_test.go b/handlers/tokenutil_test.go new file mode 100644 index 0000000..3402fa4 --- /dev/null +++ b/handlers/tokenutil_test.go @@ -0,0 +1,64 @@ +package handlers_test + +import ( + "fmt" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/atmiguel/cerealnotes/handlers" + "github.com/atmiguel/cerealnotes/models" +) + +func TestToken(t *testing.T) { + env := &handlers.Environment{nil, []byte("TheWorld")} + + var num models.UserId = 32 + bob, err := handlers.CreateTokenAsString(env, num, 1) + if err != nil { + panic(err) + } + + token, err := handlers.ParseTokenFromString(env, bob) + if err != nil { + panic(err) + } + fmt.Println(bob) + if claims, ok := token.Claims.(*handlers.JwtTokenClaim); ok && token.Valid { + if claims.UserId != 32 { + fmt.Println("error in token") + t.FailNow() + } + fmt.Printf("%v %v", claims.UserId, claims.StandardClaims.ExpiresAt) + } else { + fmt.Println("Token claims could not be read") + t.FailNow() + } +} + +func assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// ok fails the test if an err is not nil. +func ok(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + tb.FailNow() + } +} + +// equals fails the test if exp is not equal to act. +func equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..3d4ccf2 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,340 @@ +package main_test + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + // "net/url" + // "io" + "io/ioutil" + "path/filepath" + "reflect" + "runtime" + "testing" + "time" + + "github.com/atmiguel/cerealnotes/handlers" + "github.com/atmiguel/cerealnotes/models" + "github.com/atmiguel/cerealnotes/paths" + "github.com/atmiguel/cerealnotes/routers" +) + +func TestLoginOrSignUpPage(t *testing.T) { + mockDb := &DiyMockDataStore{} + env := &handlers.Environment{mockDb, []byte("")} + + server := httptest.NewServer(routers.DefineRoutes(env)) + defer server.Close() + + resp, err := http.Get(server.URL) + ok(t, err) + equals(t, http.StatusOK, resp.StatusCode) +} + +func TestAuthenticatedFlow(t *testing.T) { + mockDb := &DiyMockDataStore{} + env := &handlers.Environment{mockDb, []byte("")} + + server := httptest.NewServer(routers.DefineRoutes(env)) + defer server.Close() + + // Create testing client + client := &http.Client{} + { + // jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + jar, err := cookiejar.New(&cookiejar.Options{}) + + if err != nil { + panic(err) + } + + client.Jar = jar + } + + // Test login + userIdAsInt := int64(1) + { + theEmail := "justsomeemail@gmail.com" + thePassword := "worldsBestPassword" + + mockDb.Func_AuthenticateUserCredentials = func(email *models.EmailAddress, password string) error { + if email.String() == theEmail && password == thePassword { + return nil + } + + return models.CredentialsNotAuthorizedError + } + + mockDb.Func_GetIdForUserWithEmailAddress = func(email *models.EmailAddress) (models.UserId, error) { + return models.UserId(userIdAsInt), nil + } + + userValues := map[string]string{"emailAddress": theEmail, "password": thePassword} + + userJsonValue, _ := json.Marshal(userValues) + + resp, err := client.Post(server.URL+paths.SessionApi, "application/json", bytes.NewBuffer(userJsonValue)) + + ok(t, err) + + equals(t, http.StatusCreated, resp.StatusCode) + } + + // Test Add Note + noteIdAsInt := int64(33) + content := "Duuude I just said something cool" + { + noteValues := map[string]string{"content": content} + + mockDb.Func_StoreNewNote = func(*models.Note) (models.NoteId, error) { + return models.NoteId(noteIdAsInt), nil + } + + noteJsonValue, _ := json.Marshal(noteValues) + + resp, err := client.Post(server.URL+paths.NoteApi, "application/json", bytes.NewBuffer(noteJsonValue)) + ok(t, err) + equals(t, http.StatusCreated, resp.StatusCode) + + type NoteResponse struct { + NoteId int64 `json:"noteId"` + } + + jsonNoteReponse := &NoteResponse{} + + err = json.NewDecoder(resp.Body).Decode(jsonNoteReponse) + ok(t, err) + + equals(t, noteIdAsInt, jsonNoteReponse.NoteId) + + resp.Body.Close() + } + + // Test get notes + { + mockDb.Func_GetMyUnpublishedNotes = func(userId models.UserId) (models.NoteMap, error) { + if userIdAsInt != int64(userId) { + return nil, errors.New("Invalid userId passed in") + } + + return models.NoteMap(map[models.NoteId]*models.Note{ + models.NoteId(noteIdAsInt): &models.Note{ + AuthorId: models.UserId(userIdAsInt), + Content: content, + CreationTime: time.Now(), + }, + }), nil + + } + + mockDb.Func_GetAllPublishedNotesVisibleBy = func(userId models.UserId) (map[int64]models.NoteMap, error) { + if userIdAsInt != int64(userId) { + return nil, errors.New("Invalid userId passed in") + } + + return map[int64]models.NoteMap{ + 1: models.NoteMap(map[models.NoteId]*models.Note{ + models.NoteId(44): &models.Note{ + AuthorId: models.UserId(99), + Content: "another note", + CreationTime: time.Now(), + }, + }), + }, nil + + } + + resp, err := client.Get(server.URL + paths.NoteApi) + ok(t, err) + equals(t, http.StatusOK, resp.StatusCode) + } + + // Test Add category + { + type CategoryForm struct { + NoteId int64 `json:"noteId"` + Category string `json:"category"` + } + + metaCategory := models.META + + categoryForm := &CategoryForm{NoteId: noteIdAsInt, Category: metaCategory.String()} + + mockDb.Func_StoreNewNoteCategoryRelationship = func(noteId models.NoteId, cat models.Category) error { + if int64(noteId) == noteIdAsInt && cat == metaCategory { + return nil + } + + return errors.New("Incorrect Data Arrived") + } + + jsonValue, _ := json.Marshal(categoryForm) + + resp, err := client.Post(server.URL+paths.CategoryApi, "application/json", bytes.NewBuffer(jsonValue)) + ok(t, err) + equals(t, http.StatusCreated, resp.StatusCode) + } + + // Test publish notes + { + mockDb.Func_PublishNotes = func(userId models.UserId) error { + return nil + } + // publish new api + resp, err := client.Post(server.URL+paths.PublicationApi, "", nil) + printBody(resp) + ok(t, err) + equals(t, http.StatusCreated, resp.StatusCode) + } + + // Delete note + { + mockDb.Func_GetUsersNotes = func(userId models.UserId) (models.NoteMap, error) { + return models.NoteMap(map[models.NoteId]*models.Note{ + models.NoteId(noteIdAsInt): &models.Note{ + AuthorId: models.UserId(userIdAsInt), + Content: content, + CreationTime: time.Now(), + }, + }), nil + } + + mockDb.Func_DeleteNoteById = func(noteid models.NoteId) error { + if int64(noteid) == noteIdAsInt { + return nil + } + + return errors.New("Somehow you didn't get the correct error") + } + + resp, err := sendDeleteRequest(client, server.URL+paths.NoteApi+"?id="+strconv.FormatInt(noteIdAsInt, 10)) + ok(t, err) + // printBody(resp) + + equals(t, http.StatusOK, resp.StatusCode) + } + +} + +// func sendDeleteRequest(client *http.Client, myUrl string, contentType string, body io.Reader) (resp *http.Response, err error) { +func sendDeleteRequest(client *http.Client, myUrl string) (resp *http.Response, err error) { + + req, err := http.NewRequest("DELETE", myUrl, nil) + + if err != nil { + return nil, err + } + + return client.Do(req) + +} + +func printBody(resp *http.Response) { + buf, bodyErr := ioutil.ReadAll(resp.Body) + if bodyErr != nil { + fmt.Print("bodyErr ", bodyErr.Error()) + return + } + + rdr1 := ioutil.NopCloser(bytes.NewBuffer(buf)) + rdr2 := ioutil.NopCloser(bytes.NewBuffer(buf)) + fmt.Printf("BODY: %q", rdr1) + resp.Body = rdr2 +} + +// Helpers + +type DiyMockDataStore struct { + Func_StoreNewNote func(*models.Note) (models.NoteId, error) + Func_StoreNewNoteCategoryRelationship func(models.NoteId, models.Category) error + Func_StoreNewUser func(string, *models.EmailAddress, string) error + Func_AuthenticateUserCredentials func(*models.EmailAddress, string) error + Func_GetIdForUserWithEmailAddress func(*models.EmailAddress) (models.UserId, error) + Func_GetUsersNotes func(models.UserId) (models.NoteMap, error) + Func_DeleteNoteById func(models.NoteId) error + Func_GetMyUnpublishedNotes func(models.UserId) (models.NoteMap, error) + Func_GetAllUsersById func() (models.UserMap, error) + Func_GetAllPublishedNotesVisibleBy func(models.UserId) (map[int64]models.NoteMap, error) + Func_PublishNotes func(models.UserId) error + Func_StoreNewPublication func(*models.Publication) (models.PublicationId, error) +} + +func (mock *DiyMockDataStore) StoreNewNote(note *models.Note) (models.NoteId, error) { + return mock.Func_StoreNewNote(note) +} + +func (mock *DiyMockDataStore) StoreNewNoteCategoryRelationship(noteId models.NoteId, cat models.Category) error { + return mock.Func_StoreNewNoteCategoryRelationship(noteId, cat) +} + +func (mock *DiyMockDataStore) StoreNewUser(str1 string, email *models.EmailAddress, str2 string) error { + return mock.Func_StoreNewUser(str1, email, str2) +} + +func (mock *DiyMockDataStore) AuthenticateUserCredentials(email *models.EmailAddress, str string) error { + return mock.Func_AuthenticateUserCredentials(email, str) +} + +func (mock *DiyMockDataStore) GetIdForUserWithEmailAddress(email *models.EmailAddress) (models.UserId, error) { + return mock.Func_GetIdForUserWithEmailAddress(email) +} + +func (mock *DiyMockDataStore) GetUsersNotes(userId models.UserId) (models.NoteMap, error) { + return mock.Func_GetUsersNotes(userId) +} + +func (mock *DiyMockDataStore) DeleteNoteById(noteId models.NoteId) error { + return mock.Func_DeleteNoteById(noteId) +} + +func (mock *DiyMockDataStore) GetMyUnpublishedNotes(userId models.UserId) (models.NoteMap, error) { + return mock.Func_GetMyUnpublishedNotes(userId) +} + +func (mock *DiyMockDataStore) GetAllUsersById() (models.UserMap, error) { + return mock.Func_GetAllUsersById() +} + +func (mock *DiyMockDataStore) GetAllPublishedNotesVisibleBy(userId models.UserId) (map[int64]models.NoteMap, error) { + return mock.Func_GetAllPublishedNotesVisibleBy(userId) +} + +func (mock *DiyMockDataStore) PublishNotes(userId models.UserId) error { + return mock.Func_PublishNotes(userId) +} + +func (mock *DiyMockDataStore) StoreNewPublication(publication *models.Publication) (models.PublicationId, error) { + return mock.Func_StoreNewPublication(publication) +} + +// assert fails the test if the condition is false. +func assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// ok fails the test if an err is not nil. +func ok(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + tb.FailNow() + } +} + +// equals fails the test if exp is not equal to act. +func equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} diff --git a/main.go b/main.go index 60fb3ab..f02a2f3 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,8 @@ import ( "net/http" "os" - "github.com/atmiguel/cerealnotes/databaseutil" "github.com/atmiguel/cerealnotes/handlers" + "github.com/atmiguel/cerealnotes/models" "github.com/atmiguel/cerealnotes/routers" ) @@ -53,15 +53,23 @@ func determineTokenSigningKey() ([]byte, error) { func main() { // Set up db + + env := &handlers.Environment{} + { databaseUrl, err := determineDatabaseUrl() if err != nil { log.Fatal(err) } - if err := databaseutil.ConnectToDatabase(databaseUrl); err != nil { + db, err := models.ConnectToDatabase(databaseUrl) + + if err != nil { log.Fatal(err) } + + env.Db = db + } // Set up token signing key @@ -70,8 +78,7 @@ func main() { if err != nil { log.Fatal(err) } - - handlers.SetTokenSigningKey(tokenSigningKey) + env.TokenSigningKey = tokenSigningKey } // Start server @@ -83,7 +90,7 @@ func main() { log.Printf("Listening on %s...\n", port) - if err := http.ListenAndServe(port, routers.DefineRoutes()); err != nil { + if err := http.ListenAndServe(port, routers.DefineRoutes(env)); err != nil { log.Fatal(err) } } diff --git a/migrations/0000_createDbs.sql b/migrations/0000_createDbs.sql index d418d6a..f272b35 100644 --- a/migrations/0000_createDbs.sql +++ b/migrations/0000_createDbs.sql @@ -1,5 +1,5 @@ -- Types -CREATE TYPE note_type AS ENUM ('predictions', 'marginalia', 'meta', 'questions'); +CREATE TYPE category_type AS ENUM ('predictions', 'marginalia', 'meta', 'questions'); -- Tables CREATE TABLE IF NOT EXISTS app_user ( @@ -19,17 +19,17 @@ CREATE TABLE IF NOT EXISTS publication ( CREATE TABLE IF NOT EXISTS note ( id bigserial PRIMARY KEY, - author_id bigint references app_user(id) NOT NULL, + author_id bigint references app_user(id) ON DELETE CASCADE NOT NULL, content text NOT NULL, creation_time timestamp NOT NULL ); CREATE TABLE IF NOT EXISTS note_to_publication_relationship ( - note_id bigint PRIMARY KEY references note(id), - publication_id bigint references publication(id) NOT NULL + note_id bigint PRIMARY KEY references note(id) ON DELETE CASCADE, + publication_id bigint references publication(id) ON DELETE CASCADE NOT NULL ); -CREATE TABLE IF NOT EXISTS note_to_type_relationship ( - note_id bigint PRIMARY KEY references note(id), - type note_type NOT NULL +CREATE TABLE IF NOT EXISTS note_to_category_relationship ( + note_id bigint PRIMARY KEY references note(id) ON DELETE CASCADE, + type category_type NOT NULL ); \ No newline at end of file diff --git a/migrations/README.md b/migrations/README.md index 9111fcf..0de104c 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -1,9 +1,14 @@ -# Locally: -1. first make sure that postgres is running. - * If installed via homebrew on a macOS: `pg_ctl -D /usr/local/var/postgres start` -2. then run migration locally - * `psql < *MIGRATION_NAME*` +# Locally: +1. install & setup postgres + * `brew install postgres` + * ``createdb `whoami` `` +2. Run postgres daemon. + * `pg_ctl start -D /usr/local/var/postgres` +3. Create cerealnotes databases + * `psql < tools/createDatabases.sql` +3. Run all the migrations on both "unittest" database (`cerealnotes_test`) and as well as the "live" database (`cerealnotes`). + * `psql [DATABASENAME] < [MIGRATION_NAME]` -# On Heroku: +# On Heroku: -1. `heroku pg:psql < *MIGRATION_NAME*` +1. `heroku pg:psql < [MIGRATION_NAME]` \ No newline at end of file diff --git a/migrations/tools/createDatabases.sql b/migrations/tools/createDatabases.sql new file mode 100644 index 0000000..6e6b188 --- /dev/null +++ b/migrations/tools/createDatabases.sql @@ -0,0 +1,2 @@ +CREATE DATABASE cerealnotes_test; +CREATE DATABASE cerealnotes; \ No newline at end of file diff --git a/migrations/tools/drop_everything.sql b/migrations/tools/drop_everything.sql new file mode 100644 index 0000000..5463955 --- /dev/null +++ b/migrations/tools/drop_everything.sql @@ -0,0 +1,11 @@ +DROP TYPE category_type CASCADE; + +DROP TABLE note_to_category_relationship CASCADE; + +DROP TABLE note_to_publication_relationship CASCADE; + +DROP TABLE publication CASCADE; + +DROP TABLE note CASCADE; + +DROP TABLE app_user CASCADE; \ No newline at end of file diff --git a/migrations/tools/truncate_tables.sql b/migrations/tools/truncate_tables.sql new file mode 100644 index 0000000..f5b94c6 --- /dev/null +++ b/migrations/tools/truncate_tables.sql @@ -0,0 +1,9 @@ +TRUNCATE note_to_publication_relationship CASCADE; + +TRUNCATE publication CASCADE; + +TRUNCATE note_to_category_relationship CASCADE; + +TRUNCATE note CASCADE; + +TRUNCATE app_user CASCADE; \ No newline at end of file diff --git a/models/category.go b/models/category.go new file mode 100644 index 0000000..514e36c --- /dev/null +++ b/models/category.go @@ -0,0 +1,60 @@ +package models + +import ( + "errors" +) + +type Category int + +const ( + MARGINALIA Category = iota + META + QUESTIONS + PREDICTIONS +) + +var categoryStrings = [...]string{ + "marginalia", + "meta", + "questions", + "predictions", +} + +var CannotDeserializeCategoryStringError = errors.New("String does not correspond to a Note Category") +var NoteAlreadyContainsCategoryError = errors.New("NoteId already has a category stored for it") + +func DeserializeCategory(input string) (Category, error) { + for i := 0; i < len(categoryStrings); i++ { + if input == categoryStrings[i] { + return Category(i), nil + } + } + return MARGINALIA, CannotDeserializeCategoryStringError +} + +func (category Category) String() string { + + if category < MARGINALIA || category > PREDICTIONS { + return "Unknown" + } + + return categoryStrings[category] +} + +func (db *DB) StoreNewNoteCategoryRelationship( + noteId NoteId, + category Category, +) error { + sqlQuery := ` + INSERT INTO note_to_category_relationship (note_id, category) + VALUES ($1, $2)` + + if _, err := db.execNoResults(sqlQuery, int64(noteId), category.String()); err != nil { + if err == UniqueConstraintError { + return NoteAlreadyContainsCategoryError + } + return err + } + + return nil +} diff --git a/models/databasehelper.go b/models/databasehelper.go new file mode 100644 index 0000000..c709357 --- /dev/null +++ b/models/databasehelper.go @@ -0,0 +1,76 @@ +package models + +import ( + // "database/sql" + "errors" + "github.com/lib/pq" +) + +// UniqueConstraintError is returned when a uniqueness constraint is violated during an insert. +var UniqueConstraintError = errors.New("postgres: unique constraint violation") + +// QueryResultContainedMultipleRowsError is returned when a query unexpectedly returns more than one row. +var QueryResultContainedMultipleRowsError = errors.New("query result unexpectedly contained multiple rows") + +// QueryResultContainedNoRowsError is returned when a query unexpectedly returns no rows. +var QueryResultContainedNoRowsError = errors.New("query result unexpectedly contained no rows") + +func convertPostgresError(err error) error { + const uniqueConstraintErrorCode = "23505" + + if postgresErr, ok := err.(*pq.Error); ok { + if postgresErr.Code == uniqueConstraintErrorCode { + return UniqueConstraintError + } + } + + return err +} + +func (db *DB) execOneResult(sqlQuery string, object interface{}, args ...interface{}) error { + + rows, err := db.Query(sqlQuery, args...) + if err != nil { + return convertPostgresError(err) + } + defer rows.Close() + + foundResult := false + for rows.Next() { + + if foundResult { + return QueryResultContainedMultipleRowsError + } + + if err := rows.Scan(object); err != nil { + return convertPostgresError(err) + } + + foundResult = true + } + + if !foundResult { + return QueryResultContainedNoRowsError + } + + if err := rows.Err(); err != nil { + return convertPostgresError(err) + } + + return nil +} + +func (db *DB) execNoResults(sqlQuery string, args ...interface{}) (int64, error) { + + res, err := db.Exec(sqlQuery, args...) + if err != nil { + return 0, convertPostgresError(err) + } + + numAffected, err := res.RowsAffected() + if err != nil { + return 0, convertPostgresError(err) + } + + return numAffected, nil +} diff --git a/models/datastore.go b/models/datastore.go new file mode 100644 index 0000000..9fca1c0 --- /dev/null +++ b/models/datastore.go @@ -0,0 +1,38 @@ +package models + +import ( + "database/sql" +) + +// ConnectToDatabase also pings the database to ensure a working connection. +func ConnectToDatabase(databaseUrl string) (*DB, error) { + tempDb, err := sql.Open("postgres", databaseUrl) + if err != nil { + return nil, err + } + + if err := tempDb.Ping(); err != nil { + return nil, err + } + + return &DB{tempDb}, nil +} + +type Datastore interface { + StoreNewNote(*Note) (NoteId, error) + StoreNewNoteCategoryRelationship(NoteId, Category) error + StoreNewUser(string, *EmailAddress, string) error + AuthenticateUserCredentials(*EmailAddress, string) error + GetIdForUserWithEmailAddress(*EmailAddress) (UserId, error) + GetUsersNotes(UserId) (NoteMap, error) + DeleteNoteById(NoteId) error + GetMyUnpublishedNotes(UserId) (NoteMap, error) + GetAllUsersById() (UserMap, error) + GetAllPublishedNotesVisibleBy(UserId) (map[int64]NoteMap, error) + PublishNotes(UserId) error + StoreNewPublication(*Publication) (PublicationId, error) +} + +type DB struct { + *sql.DB +} diff --git a/models/datastore_test.go b/models/datastore_test.go new file mode 100644 index 0000000..6a9815a --- /dev/null +++ b/models/datastore_test.go @@ -0,0 +1,198 @@ +package models_test + +import ( + "fmt" + "path/filepath" + "reflect" + "runtime" + "strconv" + "testing" + "time" + + "github.com/atmiguel/cerealnotes/models" +) + +var postgresUrl = "postgresql://localhost/cerealnotes_test?sslmode=disable" + +const noteTable = "note" +const publicationTable = "publication" +const noteToPublicationTable = "note_to_publication_relationship" +const noteToCategoryTable = "note_to_category_relationship" +const userTable = "app_user" + +var tables = []string{ + noteToPublicationTable, + publicationTable, + noteToCategoryTable, + noteTable, + userTable, +} + +func ClearAllValuesInTable(db *models.DB) { + for _, val := range tables { + if err := ClearValuesInTable(db, val); err != nil { + panic(err) + } + } +} + +func ClearValuesInTable(db *models.DB, table string) error { + // db.Query() doesn't allow varaibles to replace columns or table names. + sqlQuery := fmt.Sprintf(`TRUNCATE %s CASCADE;`, table) + + _, err := db.Exec(sqlQuery) + if err != nil { + return err + } + + return nil +} + +func TestUser(t *testing.T) { + db, err := models.ConnectToDatabase(postgresUrl) + ok(t, err) + ClearValuesInTable(db, userTable) + + displayName := "boby" + password := "aPassword" + emailAddress := models.NewEmailAddress("thisIsMyOtherEmail@gmail.com") + + err = db.StoreNewUser(displayName, emailAddress, password) + ok(t, err) + + id, err := db.GetIdForUserWithEmailAddress(emailAddress) + ok(t, err) + + err = db.AuthenticateUserCredentials(emailAddress, password) + ok(t, err) + + userMap, err := db.GetAllUsersById() + ok(t, err) + + equals(t, 1, len(userMap)) + + user, isOk := userMap[id] + assert(t, isOk, "Expected UserId missing") + + equals(t, displayName, user.DisplayName) +} + +func TestNote(t *testing.T) { + db, err := models.ConnectToDatabase(postgresUrl) + ok(t, err) + ClearValuesInTable(db, userTable) + ClearValuesInTable(db, noteTable) + + displayName := "bob" + password := "aPassword" + emailAddress := models.NewEmailAddress("thisIsMyEmail@gmail.com") + + err = db.StoreNewUser(displayName, emailAddress, password) + ok(t, err) + + userId, err := db.GetIdForUserWithEmailAddress(emailAddress) + ok(t, err) + + note := &models.Note{AuthorId: userId, Content: "I'm a note", CreationTime: time.Now()} + id, err := db.StoreNewNote(note) + ok(t, err) + assert(t, int64(id) > 0, "Note Id was not a valid index: "+strconv.Itoa(int(id))) + + notemap, err := db.GetMyUnpublishedNotes(userId) + ok(t, err) + + retrievedNote, isOk := notemap[id] + assert(t, isOk, "Expected NoteId missing") + + equals(t, note.AuthorId, retrievedNote.AuthorId) + equals(t, note.Content, retrievedNote.Content) + + err = db.DeleteNoteById(id) + ok(t, err) +} + +func TestPublication(t *testing.T) { + db, err := models.ConnectToDatabase(postgresUrl) + ok(t, err) + ClearValuesInTable(db, userTable) + ClearValuesInTable(db, noteTable) + ClearValuesInTable(db, publicationTable) + ClearValuesInTable(db, noteToPublicationTable) + + displayName := "bob" + password := "aPassword" + emailAddress := models.NewEmailAddress("thisIsMyEmail@gmail.com") + + err = db.StoreNewUser(displayName, emailAddress, password) + ok(t, err) + + userId, err := db.GetIdForUserWithEmailAddress(emailAddress) + ok(t, err) + + note := &models.Note{AuthorId: userId, Content: "I'm a note", CreationTime: time.Now()} + id, err := db.StoreNewNote(note) + ok(t, err) + assert(t, int64(id) > 0, "Note Id was not a valid index: "+strconv.Itoa(int(id))) + + publicationToNoteMap, err := db.GetAllPublishedNotesVisibleBy(userId) + ok(t, err) + + equals(t, 0, len(publicationToNoteMap)) + + err = db.PublishNotes(userId) + ok(t, err) + + publicationToNoteMap, err = db.GetAllPublishedNotesVisibleBy(userId) + equals(t, 1, len(publicationToNoteMap)) +} + +func TestCategory(t *testing.T) { + db, err := models.ConnectToDatabase(postgresUrl) + ok(t, err) + ClearValuesInTable(db, userTable) + ClearValuesInTable(db, noteTable) + ClearValuesInTable(db, noteToCategoryTable) + + displayName := "bob" + password := "aPassword" + emailAddress := models.NewEmailAddress("thisyetAnotherIsMyEmail@gmail.com") + + err = db.StoreNewUser(displayName, emailAddress, password) + ok(t, err) + + userId, err := db.GetIdForUserWithEmailAddress(emailAddress) + ok(t, err) + + note := &models.Note{AuthorId: userId, Content: "I'm a note", CreationTime: time.Now()} + noteId, err := db.StoreNewNote(note) + ok(t, err) + + err = db.StoreNewNoteCategoryRelationship(noteId, models.META) + ok(t, err) +} + +func assert(tb testing.TB, condition bool, msg string, v ...interface{}) { + if !condition { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) + tb.FailNow() + } +} + +// ok fails the test if an err is not nil. +func ok(tb testing.TB, err error) { + if err != nil { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) + tb.FailNow() + } +} + +// equals fails the test if exp is not equal to act. +func equals(tb testing.TB, exp, act interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + fmt.Printf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", filepath.Base(file), line, exp, act) + tb.FailNow() + } +} diff --git a/models/note.go b/models/note.go index 255a250..986e268 100644 --- a/models/note.go +++ b/models/note.go @@ -1,37 +1,192 @@ package models import ( + "errors" "time" ) type NoteId int64 -type NoteType int +type Note struct { + AuthorId UserId `json:"authorId"` + Content string `json:"content"` + CreationTime time.Time `json:"creationTime"` +} -const ( - MARGINALIA NoteType = iota - META - QUESTIONS - PREDICTIONS -) +var NoNoteFoundError = errors.New("No note with that information could be found") + +// DB methods -var noteTypeStrings = [...]string{ - "marginalia", - "meta", - "questions", - "predictions", +func (db *DB) StoreNewNote( + note *Note, +) (NoteId, error) { + + authorId := int64(note.AuthorId) + content := note.Content + creationTime := note.CreationTime + + sqlQuery := ` + INSERT INTO note (author_id, content, creation_time) + VALUES ($1, $2, $3) + RETURNING id` + + var noteId int64 = 0 + if err := db.execOneResult(sqlQuery, ¬eId, authorId, content, creationTime); err != nil { + return 0, err + } + return NoteId(noteId), nil } -func (noteType NoteType) String() string { - if noteType < MARGINALIA || noteType > PREDICTIONS { - return "Unknown" +func (db *DB) GetUsersNotes(userId UserId) (NoteMap, error) { + sqlQuery := ` + SELECT id, author_id, content, creation_time FROM note + WHERE author_id = $1` + + noteMap, err := db.getNoteMap(sqlQuery, int64(userId)) + if err != nil { + return nil, err } - return noteTypeStrings[noteType] + return noteMap, nil } -type Note struct { - AuthorId UserId `json:"authorId"` - Content string `json:"content"` - CreationTime time.Time `json:"creationTime"` +func (db *DB) GetAllPublishedNotesVisibleBy(userId UserId) (map[int64]NoteMap, error) { + + sqlQueryIssueNumber := ` + SELECT COUNT(*) AS IssueNumber FROM publication + WHERE publication.author_id = $1` + + var publictionIssueNumber int64 + if err := db.execOneResult(sqlQueryIssueNumber, &publictionIssueNumber, int64(userId)); err != nil { + return nil, err + } + + sqlQueryGetNotes := ` + SELECT + note.id, + note.author_id, + note.content, + note.creation_time, + filtered_pubs.rank AS publication_issue + FROM (SELECT *, + Rank() + OVER( + partition BY pub.author_id + ORDER BY pub.creation_time) + FROM publication AS pub) filtered_pubs + INNER JOIN note_to_publication_relationship AS note2pub + ON note2pub.publication_id = filtered_pubs.id + INNER JOIN note + ON note.id = note2pub.note_id + WHERE rank <= ($1)` + + // sqlQueryGetNotes := ` + // SELECT + // note.id, + // note.author_id, + // note.content, + // note.creation_time, + // note2cat.type AS category, + // filtered_pubs.rank AS publication_issue + // FROM (SELECT *, + // Rank() + // OVER( + // partition BY pub.author_id + // ORDER BY pub.creation_time) + // FROM publication AS pub) filtered_pubs + // INNER JOIN note_to_publication_relationship AS note2pub + // ON note2pub.publication_id = filtered_pubs.id + // INNER JOIN note + // ON note.id = note2pub.note_id + // LEFT OUTER JOIN note_to_category_relationship AS note2cat + // ON note.id = note2cat.note_id + // WHERE rank <= ($1)` + + rows, err := db.Query(sqlQueryGetNotes, publictionIssueNumber) + if err != nil { + return nil, convertPostgresError(err) + } + + defer rows.Close() + + pubToNoteMap := make(map[int64]NoteMap) + + for rows.Next() { + var publicationNumber int64 + var noteId int64 + note := &Note{} + if err := rows.Scan(¬eId, ¬e.AuthorId, ¬e.Content, ¬e.CreationTime, &publicationNumber); err != nil { + return nil, err + } + + noteMap, ok := pubToNoteMap[publicationNumber] + if !ok { + pubToNoteMap[publicationNumber] = make(map[NoteId]*Note) + noteMap = pubToNoteMap[publicationNumber] + } + + noteMap[NoteId(noteId)] = note + + } + + return pubToNoteMap, nil +} + +func (db *DB) GetMyUnpublishedNotes(userId UserId) (NoteMap, error) { + sqlQuery := ` + SELECT id, author_id, content, creation_time FROM note + LEFT OUTER JOIN note_to_publication_relationship AS note2pub + ON note.id = note2pub.note_id + WHERE note2pub.note_id is NULL AND note.author_id = $1` + + noteMap, err := db.getNoteMap(sqlQuery, int64(userId)) + if err != nil { + return nil, err + } + + return noteMap, nil +} + +func (db *DB) getNoteMap(sqlQuery string, args ...interface{}) (NoteMap, error) { + + noteMap := make(map[NoteId]*Note) + + rows, err := db.Query(sqlQuery, args...) + if err != nil { + return nil, convertPostgresError(err) + } + defer rows.Close() + + for rows.Next() { + var tempId int64 + tempNote := &Note{} + if err := rows.Scan(&tempId, &tempNote.AuthorId, &tempNote.Content, &tempNote.CreationTime); err != nil { + return nil, convertPostgresError(err) + } + + noteMap[NoteId(tempId)] = tempNote + } + + return noteMap, nil +} + +func (db *DB) DeleteNoteById(noteId NoteId) error { + sqlQuery := ` + DELETE FROM note + WHERE id = $1` + + num, err := db.execNoResults(sqlQuery, int64(noteId)) + if err != nil { + return err + } + + if num == 0 { + return NoNoteFoundError + } + + if num != 1 { + return errors.New("somehow more than 1 note was deleted") + } + + return nil } diff --git a/models/note_test.go b/models/note_test.go new file mode 100644 index 0000000..8787761 --- /dev/null +++ b/models/note_test.go @@ -0,0 +1,25 @@ +package models_test + +import ( + "testing" + + "github.com/atmiguel/cerealnotes/models" +) + +var deserializationTests = []models.Category{ + models.MARGINALIA, + models.META, + models.QUESTIONS, + models.PREDICTIONS, +} + +func TestDeserialization(t *testing.T) { + for _, val := range deserializationTests { + t.Run(val.String(), func(t *testing.T) { + cat, err := models.DeserializeCategory(val.String()) + ok(t, err) + equals(t, val, cat) + }) + } + +} diff --git a/models/notemap.go b/models/notemap.go new file mode 100644 index 0000000..1913baa --- /dev/null +++ b/models/notemap.go @@ -0,0 +1,19 @@ +package models + +import ( + "encoding/json" + "fmt" +) + +type NoteMap map[NoteId]*Note + +func (noteMap NoteMap) ToJson() ([]byte, error) { + // json doesn't support int indexed maps + notesByIdString := make(map[string]Note, len(noteMap)) + + for id, note := range noteMap { + notesByIdString[fmt.Sprint(id)] = *note + } + + return json.Marshal(notesByIdString) +} diff --git a/models/publication.go b/models/publication.go index 6091325..30a94b6 100644 --- a/models/publication.go +++ b/models/publication.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "errors" + "strconv" + "time" +) type PublicationId int64 @@ -8,3 +12,74 @@ type Publication struct { AuthorId UserId `json:"authorId"` CreationTime time.Time `json:"creationTime"` } + +var NoNotesToPublishError = errors.New("There are no unpublished notes to publish") + +func (db *DB) PublishNotes(userId UserId) error { + + myUnpublishedNotes, err := db.GetMyUnpublishedNotes(userId) + if err != nil { + return err + } + + if len(myUnpublishedNotes) == 0 { + return NoNotesToPublishError + } + + publicationId, err := db.StoreNewPublication(&Publication{AuthorId: userId, CreationTime: time.Now().UTC()}) + + sqlQuery := ` + INSERT INTO note_to_publication_relationship (publication_id, note_id) + VALUES ($1, $2)` + + noteIds := make([]int64, len(myUnpublishedNotes)) + + { + i := 0 + for noteId := range myUnpublishedNotes { + noteIds[i] = int64(noteId) + i++ + } + } + + values := make([]interface{}, len(noteIds)*2, len(noteIds)*2) + { + values[0] = publicationId + values[1] = noteIds[0] + for index, noteId := range noteIds { + if index == 0 { + continue + } + sqlQuery += ", ($" + strconv.Itoa(2*index+1) + ", $" + strconv.Itoa((2*index)+2) + ")" + values[2*index] = publicationId + values[(2*index)+1] = noteId + } + } + + rowAffected, err := db.execNoResults(sqlQuery, values...) + if err != nil { + return err + } + + if rowAffected < 1 { + return errors.New("No values were inserted") + } + + return nil + +} + +func (db *DB) StoreNewPublication(publication *Publication) (PublicationId, error) { + + sqlQuery := ` + INSERT INTO publication (author_id, creation_time) + VALUES ($1, $2) + RETURNING id` + + var publicationId int64 = 0 + if err := db.execOneResult(sqlQuery, &publicationId, int64(publication.AuthorId), publication.CreationTime); err != nil { + return 0, err + } + + return PublicationId(publicationId), nil +} diff --git a/models/user.go b/models/user.go index 0ecac0c..9fbc165 100644 --- a/models/user.go +++ b/models/user.go @@ -1,6 +1,14 @@ package models -import "strings" +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) type UserId int64 @@ -20,3 +28,120 @@ func NewEmailAddress(emailAddressAsString string) *EmailAddress { func (emailAddress *EmailAddress) String() string { return emailAddress.emailAddressAsString } + +var EmailAddressAlreadyInUseError = errors.New("Email address already in use") + +var CredentialsNotAuthorizedError = errors.New("The provided credentials were not found") + +type UserMap map[UserId]*User + +func (userMap UserMap) ToJson() ([]byte, error) { + // json doesn't support int indexed maps + userByIdString := make(map[string]User, len(userMap)) + + for id, user := range userMap { + userByIdString[fmt.Sprint(id)] = *user + } + + return json.Marshal(userByIdString) +} + +// + +func (db *DB) StoreNewUser( + displayName string, + emailAddress *EmailAddress, + password string, +) error { + hashedPassword, err := bcrypt.GenerateFromPassword( + []byte(password), + bcrypt.DefaultCost) + if err != nil { + return err + } + + creationTime := time.Now().UTC() + + sqlQuery := ` + INSERT INTO app_user (display_name, email_address, password, creation_time) + VALUES ($1, $2, $3, $4)` + + if _, err := db.execNoResults(sqlQuery, displayName, emailAddress.String(), hashedPassword, creationTime); err != nil { + if err == UniqueConstraintError { + return EmailAddressAlreadyInUseError + } + + return err + } + + return nil +} + +func (db *DB) AuthenticateUserCredentials(emailAddress *EmailAddress, password string) error { + sqlQuery := ` + SELECT password FROM app_user + WHERE email_address = $1` + + var storedHashedPassword []byte + + if err := db.execOneResult(sqlQuery, &storedHashedPassword, emailAddress.String()); err != nil { + return err + } + + if err := bcrypt.CompareHashAndPassword( + storedHashedPassword, + []byte(password), + ); err != nil { + if err == bcrypt.ErrMismatchedHashAndPassword { + return CredentialsNotAuthorizedError + } + + return err + } + + return nil +} + +func (db *DB) GetIdForUserWithEmailAddress(emailAddress *EmailAddress) (UserId, error) { + sqlQuery := ` + SELECT id FROM app_user + WHERE email_address = $1` + + var userId int64 + if err := db.execOneResult(sqlQuery, &userId, emailAddress.String()); err != nil { + if err == QueryResultContainedNoRowsError { + return 0, CredentialsNotAuthorizedError + } + return 0, err + } + + return UserId(userId), nil +} + +func (db *DB) GetAllUsersById() (UserMap, error) { + sqlQuery := ` + SELECT id, display_name FROM app_user` + + rows, err := db.Query(sqlQuery) + if err != nil { + return nil, convertPostgresError(err) + } + + defer rows.Close() + + var userMap UserMap = make(map[UserId]*User) + + for rows.Next() { + var tempId int64 + user := &User{} + if err := rows.Scan(&tempId, &user.DisplayName); err != nil { + return nil, convertPostgresError(err) + } + + userMap[UserId(tempId)] = user + + } + + return userMap, nil + +} diff --git a/paths/paths.go b/paths/paths.go index 8db612c..4d84ff2 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -8,7 +8,9 @@ const ( HomePage = "/home" NotesPage = "/notes" - UserApi = "/api/user" - SessionApi = "/api/session" - NoteApi = "/api/note" + UserApi = "/api/user" + SessionApi = "/api/session" + NoteApi = "/api/note" + CategoryApi = "/api/category" + PublicationApi = "/api/publication" ) diff --git a/routers/routers.go b/routers/routers.go index 6bcd2b5..96733ae 100644 --- a/routers/routers.go +++ b/routers/routers.go @@ -10,9 +10,37 @@ import ( "github.com/atmiguel/cerealnotes/paths" ) +type routeHandler struct { + *http.ServeMux +} + +func (mux *routeHandler) handleAuthenticatedPage( + env *handlers.Environment, + pattern string, + handlerFunc handlers.AuthenticatedRequestHandlerType, +) { + mux.HandleFunc(pattern, handlers.AuthenticateOrRedirect(env, handlerFunc, paths.LoginOrSignupPage)) +} + +func (mux *routeHandler) handleAuthenticatedApi( + env *handlers.Environment, + pattern string, + handlerFunc handlers.AuthenticatedRequestHandlerType, +) { + mux.HandleFunc(pattern, handlers.AuthenticateOrReturnUnauthorized(env, handlerFunc)) +} + +func (mux *routeHandler) handleUnAutheticedRequest( + env *handlers.Environment, + pattern string, + handlerFunc handlers.UnauthenticatedEndpointHandlerType, +) { + mux.HandleFunc(pattern, handlers.WrapUnauthenticatedEndpoint(env, handlerFunc)) +} + // DefineRoutes returns a new servemux with all the required path and handler pairs attached. -func DefineRoutes() http.Handler { - mux := http.NewServeMux() +func DefineRoutes(env *handlers.Environment) http.Handler { + mux := &routeHandler{http.NewServeMux()} // static files { staticDirectoryName := "static" @@ -27,28 +55,23 @@ func DefineRoutes() http.Handler { // Redirects mux.HandleFunc("/", handlers.RedirectToPathHandler(paths.HomePage)) + mux.HandleFunc("/api/", http.NotFound) mux.HandleFunc("/favicon.ico", handlers.RedirectToPathHandler("/static/favicon.ico")) // pages - mux.HandleFunc(paths.LoginOrSignupPage, handlers.HandleLoginOrSignupPageRequest) + mux.handleUnAutheticedRequest(env, paths.LoginOrSignupPage, handlers.HandleLoginOrSignupPageRequest) - handleAuthenticated(mux, paths.HomePage, handlers.HandleHomePageRequest) - handleAuthenticated(mux, paths.NotesPage, handlers.HandleNotesPageRequest) + mux.handleAuthenticatedPage(env, paths.HomePage, handlers.HandleHomePageRequest) + mux.handleAuthenticatedPage(env, paths.NotesPage, handlers.HandleNotesPageRequest) // api - mux.HandleFunc(paths.UserApi, handlers.HandleUserApiRequest) - mux.HandleFunc(paths.SessionApi, handlers.HandleSessionApiRequest) + mux.handleUnAutheticedRequest(env, paths.UserApi, handlers.HandleUserApiRequest) + mux.handleUnAutheticedRequest(env, paths.SessionApi, handlers.HandleSessionApiRequest) - handleAuthenticated(mux, paths.NoteApi, handlers.HandleNoteApiRequest) + mux.handleAuthenticatedApi(env, paths.NoteApi, handlers.HandleNoteApiRequest) + mux.handleAuthenticatedApi(env, paths.CategoryApi, handlers.HandleCategoryApiRequest) + mux.handleAuthenticatedApi(env, paths.PublicationApi, handlers.HandlePublicationApiRequest) return mux } - -func handleAuthenticated( - mux *http.ServeMux, - pattern string, - handlerFunc handlers.AuthentictedRequestHandlerType, -) { - mux.HandleFunc(pattern, handlers.AuthenticateOrRedirectToLogin(handlerFunc)) -} diff --git a/services/userservice/userservice.go b/services/userservice/userservice.go deleted file mode 100644 index fab7356..0000000 --- a/services/userservice/userservice.go +++ /dev/null @@ -1,92 +0,0 @@ -/* -Package userservice handles interactions with database layer. -*/ -package userservice - -import ( - "errors" - "time" - - "github.com/atmiguel/cerealnotes/databaseutil" - "github.com/atmiguel/cerealnotes/models" - "golang.org/x/crypto/bcrypt" -) - -var EmailAddressAlreadyInUseError = errors.New("Email address already in use") - -var CredentialsNotAuthorizedError = errors.New("The provided credentials were not found") - -func StoreNewUser( - displayName string, - emailAddress *models.EmailAddress, - password string, -) error { - hashedPassword, err := bcrypt.GenerateFromPassword( - []byte(password), - bcrypt.DefaultCost) - if err != nil { - return err - } - - creationTime := time.Now().UTC() - - if err := databaseutil.InsertIntoUserTable( - displayName, - emailAddress.String(), - hashedPassword, - creationTime, - ); err != nil { - if err == databaseutil.UniqueConstraintError { - return EmailAddressAlreadyInUseError - } - - return err - } - - return nil -} - -func AuthenticateUserCredentials(emailAddress *models.EmailAddress, password string) error { - storedHashedPassword, err := databaseutil.GetPasswordForUserWithEmailAddress(emailAddress.String()) - if err != nil { - if err == databaseutil.QueryResultContainedMultipleRowsError { - return err // would normally throw a runtime here - } - - if err == databaseutil.QueryResultContainedNoRowsError { - return CredentialsNotAuthorizedError - } - - return err - } - - if err := bcrypt.CompareHashAndPassword( - storedHashedPassword, - []byte(password), - ); err != nil { - if err == bcrypt.ErrMismatchedHashAndPassword { - return CredentialsNotAuthorizedError - } - - return err - } - - return nil -} - -func GetIdForUserWithEmailAddress(emailAddress *models.EmailAddress) (models.UserId, error) { - userIdAsInt, err := databaseutil.GetIdForUserWithEmailAddress(emailAddress.String()) - if err != nil { - if err == databaseutil.QueryResultContainedMultipleRowsError { - return 0, err // would normally throw a runtime here - } - - if err == databaseutil.QueryResultContainedNoRowsError { - return 0, err - } - - return 0, err - } - - return models.UserId(userIdAsInt), nil -} diff --git a/static/js/base.js b/static/js/base.js new file mode 100644 index 0000000..2143555 --- /dev/null +++ b/static/js/base.js @@ -0,0 +1,32 @@ +$(function() { + // http://stepansuvorov.com/blog/2014/04/jquery-put-and-delete/ + jQuery.each( [ "put", "delete" ], function( i, method ) { + jQuery[ method ] = function( url, data, callback, type ) { + if ( jQuery.isFunction( data ) ) { + type = type || callback; + callback = data; + data = undefined; + } + + return jQuery.ajax({ + url: url, + type: method, + dataType: type, + data: data, + success: callback + }); + }; + }); + + jQuery.prototype.getDOM = function() { + if (this.length === 1) { + return this[0]; + } + + if (this.length === 0) { + throw "jQuery object is empty" + } + throw "jQuery Object contains more than 1 object"; + }; + +}); \ No newline at end of file diff --git a/static/js/notes.js b/static/js/notes.js index bb8bff9..f3d1d38 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -22,7 +22,7 @@ const $createDivider = function() { return $('', {text: ' - '}); }; -const $createNote = function(note) { +const $createNote = function(noteId, note) { const $author = $createAuthor(note.authorId); const $type = $createType(note.type); const $creationTime = $createCreationTime(note.creationTime); @@ -45,11 +45,9 @@ $(function() { $.get('/api/note', function(notes) { const $notes = $('#notes'); - notes.forEach((note) => { - $notes.append( - $createNote(note) - ); - }); + for (const key of Object.keys(notes)) { + $notes.append($createNote(key, notes[key])); + } }); }); }); diff --git a/templates/base.tmpl b/templates/base.tmpl index 02de033..6a2d76c 100644 --- a/templates/base.tmpl +++ b/templates/base.tmpl @@ -20,6 +20,7 @@ + {{ block "js" . }} {{ end }} diff --git a/tools/drop_everything.sql b/tools/drop_everything.sql deleted file mode 100644 index babfd3f..0000000 --- a/tools/drop_everything.sql +++ /dev/null @@ -1,11 +0,0 @@ -DROP TYPE note_type CASCADE; - -DROP TABLE app_user CASCADE; - -DROP TABLE publication CASCADE; - -DROP TABLE note CASCADE; - -DROP TABLE note_to_type_relationship CASCADE; - -DROP TABLE note_to_publication_relationship CASCADE; \ No newline at end of file