diff --git a/backend/main/models/community.go b/backend/main/models/community.go index 96db53593..ac253a37c 100644 --- a/backend/main/models/community.go +++ b/backend/main/models/community.go @@ -47,6 +47,7 @@ type Community struct { Timestamp string `json:"timestamp" validate:"required"` Composite_signatures *[]s.CompositeSignature `json:"compositeSignatures"` Creator_addr string `json:"creatorAddr" validate:"required"` + Creator_image *string `json:"creatorImage,omitempty"` Signing_addr *string `json:"signingAddr,omitempty"` Voucher *shared.Voucher `json:"voucher,omitempty"` Created_at *time.Time `json:"createdAt,omitempty"` @@ -96,7 +97,7 @@ type CanUserCreateProposalResponse struct { IsAuthor bool `json:"isAuthor"` HasPermission bool `json:"hasPermission"` Reason string `json:"reason,omitempty"` - Contract_type string `json:"contractType,omitempty"` + Contract_type string `json:"contractType,omitempty"` Error error `json:"error,omitempty"` } @@ -201,15 +202,19 @@ func GetCommunityTypes(db *s.Database) ([]*CommunityType, error) { func (c *Community) GetCommunity(db *s.Database) error { return pgxscan.Get(db.Context, db.Conn, c, - `SELECT * from communities WHERE id = $1`, - c.ID) + `SELECT communities.*, users.profile_image AS creator_image + FROM communities + JOIN users on users.addr = communities.creator_addr + WHERE id = $1`, c.ID) } func GetCommunities(db *s.Database, pageParams shared.PageParams) ([]*Community, int, error) { var communities []*Community err := pgxscan.Select(db.Context, db.Conn, &communities, ` - SELECT * FROM communities + SELECT communities.*, users.profile_image AS creator_image + FROM communities + JOIN users on users.addr = communities.creator_addr LIMIT $1 OFFSET $2 `, pageParams.Count, pageParams.Start) diff --git a/backend/main/models/proposal.go b/backend/main/models/proposal.go index c1347370b..4f4b0aa0c 100644 --- a/backend/main/models/proposal.go +++ b/backend/main/models/proposal.go @@ -26,6 +26,7 @@ type Proposal struct { Max_weight *float64 `json:"maxWeight,omitempty"` Min_balance *float64 `json:"minBalance,omitempty"` Creator_addr string `json:"creatorAddr" validate:"required"` + Creator_image *string `json:"creatorImage,omitempty"` Start_time time.Time `json:"startTime" validate:"required"` Result *string `json:"result,omitempty"` End_time time.Time `json:"endTime" validate:"required"` @@ -87,7 +88,14 @@ func GetProposalsForCommunity( var err error // Get Proposals - sql := fmt.Sprintf(`SELECT *, %s FROM proposals WHERE community_id = $3`, computedStatusSQL) + sql := fmt.Sprintf( + `SELECT p.*, u.profile_image as creator_image, %s + FROM proposals as p + left join users as u on u.addr = p.creator_addr + WHERE community_id = $3 + `, + computedStatusSQL, + ) statusesFilterSql := generateStatusesFilterSQL(statuses) orderBySql := fmt.Sprintf(` ORDER BY created_at %s`, params.Order) @@ -114,10 +122,12 @@ func GetProposalsForCommunity( func (p *Proposal) GetProposalById(db *s.Database) error { sql := ` - SELECT p.*, %s, count(v.id) as total_votes from proposals as p + SELECT p.*, %s, u.profile_image as creator_image, + COUNT(v.id) as total_votes from proposals as p left join votes as v on v.proposal_id = p.id + left join users as u on u.addr = p.creator_addr WHERE p.id = $1 - GROUP BY p.id` + GROUP BY p.id, u.profile_image` sql = fmt.Sprintf(sql, computedStatusSQL) return pgxscan.Get(db.Context, db.Conn, p, sql, p.ID) } diff --git a/backend/main/models/user.go b/backend/main/models/user.go new file mode 100644 index 000000000..a45c63cb6 --- /dev/null +++ b/backend/main/models/user.go @@ -0,0 +1,114 @@ +package models + +import ( + "github.com/DapperCollectives/CAST/backend/main/shared" + "github.com/google/uuid" +) + +type User struct { + Uuid *string `json:"uuid,omitempty"` + Addr *string `json:"address,validate:required"` + Composite_signatures *[]shared.CompositeSignature `json:"compositeSignatures,validate:required"` + Timestamp *string `json:"timestamp,validate:required"` + Profile_image *string `json:"profileImage,omitempty"` + Name *string `json:"name,omitempty"` + Website *string `json:"website,omitempty"` + Bio *string `json:"bio,omitempty"` + Twitter *string `json:"twitter,omitempty"` + Discord *string `json:"discord,omitempty"` + Instagram *string `json:"instagram,omitempty"` +} + +func (u *User) CreateUser(db *shared.Database, payload *User) error { + err := db.Conn.QueryRow(db.Context, + `INSERT INTO users (uuid, addr, profile_image, name, website, bio, + twitter, discord, instagram) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + uuid.New(), + payload.Addr, + payload.Profile_image, + payload.Name, + payload.Website, + payload.Bio, + payload.Twitter, + payload.Discord, + payload.Instagram). + Scan( + &u.Uuid, + &u.Addr, + &u.Profile_image, + &u.Name, + &u.Website, + &u.Bio, + &u.Twitter, + &u.Discord, + &u.Instagram, + ) + + if err != nil { + return err + } + return nil +} + +func (u *User) GetUser(db *shared.Database, addr string) error { + err := db.Conn.QueryRow( + db.Context, + "SELECT * FROM users WHERE addr = $1", + addr).Scan( + &u.Uuid, + &u.Addr, + &u.Profile_image, + &u.Name, + &u.Website, + &u.Bio, + &u.Twitter, + &u.Discord, + &u.Instagram, + ) + + if err != nil { + return err + } + return nil +} + +func (u *User) UpdateUser(db *shared.Database, payload *User) error { + err := db.Conn.QueryRow( + db.Context, + `UPDATE users + SET profile_image = COALESCE($1,profile_image), + name = COALESCE($2, name), + website = COALESCE($3, website), + bio = COALESCE($4, bio), + twitter = COALESCE($5, twitter), + discord = COALESCE($6, discord), + instagram = COALESCE($7, instagram) + WHERE addr = $8 + RETURNING *`, + payload.Profile_image, + payload.Name, + payload.Website, + payload.Bio, + payload.Twitter, + payload.Discord, + payload.Instagram, + payload.Addr, + ).Scan( + &u.Uuid, + &u.Addr, + &u.Profile_image, + &u.Name, + &u.Website, + &u.Bio, + &u.Twitter, + &u.Discord, + &u.Instagram, + ) + + if err != nil { + return err + } + return nil +} diff --git a/backend/main/server/controllers.go b/backend/main/server/controllers.go index 750222315..e31b07ce7 100644 --- a/backend/main/server/controllers.go +++ b/backend/main/server/controllers.go @@ -122,6 +122,13 @@ var ( Details: "The proposal you are trying to access no longer exists.", } + errUserNotFound = errorResponse{ + StatusCode: http.StatusNotFound, + ErrorCode: "ERR_1015", + Message: "User not found", + Details: "The user you are trying to access does not exist.", + } + nilErr = errorResponse{} ) @@ -910,6 +917,67 @@ func (a *App) createCommunityUser(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, http.StatusCreated, "OK") } +func (a *App) getUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + addr := vars["addr"] + + var user models.User + if err := user.GetUser(a.DB, addr); err != nil { + log.Error().Err(err).Msg("Error user not found") + respondWithError(w, errUserNotFound) + return + } + + respondWithJSON(w, http.StatusOK, user) +} + +func (a *App) createUser(w http.ResponseWriter, r *http.Request) { + payload := models.User{} + + if err := validatePayload(r.Body, &payload); err != nil { + log.Error().Err(err).Msg("Error validating payload") + respondWithError(w, errIncompleteRequest) + return + } + + if err := helpers.validateUser( + *payload.Addr, + *payload.Timestamp, + payload.Composite_signatures, + ); err != nil { + log.Error().Err(err).Msg("Error validating signature") + respondWithError(w, errIncompleteRequest) + return + } + + var user models.User + if err := user.CreateUser(a.DB, &payload); err != nil { + log.Error().Err(err).Msg("Error creating user") + respondWithError(w, errIncompleteRequest) + return + } + respondWithJSON(w, http.StatusCreated, user) +} + +func (a *App) updateUser(w http.ResponseWriter, r *http.Request) { + payload := models.User{} + + if err := validatePayload(r.Body, &payload); err != nil { + log.Error().Err(err).Msg("Error validating payload") + respondWithError(w, errIncompleteRequest) + return + } + + var user models.User + + if err := user.UpdateUser(a.DB, &payload); err != nil { + log.Error().Err(err).Msg("Error updating user") + respondWithError(w, errUserNotFound) + return + } + respondWithJSON(w, http.StatusOK, user) +} + func (a *App) getCommunityUsers(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) communityId, err := strconv.Atoi(vars["communityId"]) diff --git a/backend/main/server/helpers.go b/backend/main/server/helpers.go index 9e8cc4920..330c2596e 100644 --- a/backend/main/server/helpers.go +++ b/backend/main/server/helpers.go @@ -1177,7 +1177,6 @@ func (h *Helpers) validateTimestamp(timestamp string, expiry int) error { } func (h *Helpers) validateUser(addr, timestamp string, compositeSignatures *[]shared.CompositeSignature) error { - if err := h.validateTimestamp(timestamp, 60); err != nil { return err } diff --git a/backend/main/server/routes.go b/backend/main/server/routes.go index 6dbd0d703..0cd5d632b 100644 --- a/backend/main/server/routes.go +++ b/backend/main/server/routes.go @@ -40,6 +40,10 @@ func (a *App) initializeRoutes() { a.Router.HandleFunc("/voting-strategies", a.getVotingStrategies).Methods("GET") a.Router.HandleFunc("/community-categories", a.getCommunityCategories).Methods("GET") // Users + a.Router.HandleFunc("/user/{addr:0x[a-zA-Z0-9]+}", a.getUser).Methods("GET") + a.Router.HandleFunc("/user", a.updateUser).Methods("PUT", "OPTIONS") + a.Router.HandleFunc("/user", a.createUser).Methods("POST", "OPTIONS") + a.Router.HandleFunc("/users/{addr:0x[a-zA-Z0-9]{16}}/communities", a.getUserCommunities).Methods("GET") a.Router.HandleFunc("/users/{addr:0x[a-zA-Z0-9]{16}}/proposals", a.getUserProposals).Methods("GET") a.Router.HandleFunc("/communities/{communityId:[0-9]+}/users", a.createCommunityUser).Methods("POST", "OPTIONS") diff --git a/backend/migrations/000044_create_user_table.down.sql b/backend/migrations/000044_create_user_table.down.sql new file mode 100644 index 000000000..c99ddcdc8 --- /dev/null +++ b/backend/migrations/000044_create_user_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/000044_create_user_table.up.sql b/backend/migrations/000044_create_user_table.up.sql new file mode 100644 index 000000000..a614229bf --- /dev/null +++ b/backend/migrations/000044_create_user_table.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + uuid UUID PRIMARY KEY NOT NULL, + addr VARCHAR(255) NOT NULL, + created_at TIMESTAMP without time zone default (now() at time zone 'utc'), + profile_image TEXT, + name VARCHAR(50), + website VARCHAR(50), + bio VARCHAR(255), + twitter VARCHAR(50), + discord VARCHAR(50), + instagram VARCHAR(50) +); diff --git a/backend/tests/test_utils/user_utils.go b/backend/tests/test_utils/user_utils.go new file mode 100644 index 000000000..e113a9853 --- /dev/null +++ b/backend/tests/test_utils/user_utils.go @@ -0,0 +1,79 @@ +package test_utils + +import ( + "bytes" + "encoding/json" + "fmt" + "time" + + "net/http" + "net/http/httptest" + + "github.com/DapperCollectives/CAST/backend/main/models" +) + +var ( + dummyProfileImage = "https://pbs.twimg.com/profile_images/1277734310/IMG_0001_400x400.JPG" + dummyName = "Test User" + dummyBio = "This is a test bio" + dummyTwitter = "https://twitter.com/testuser" + dummyDiscord = "https://discord.com/testuser" + dummyInstagram = "https://instagram.com/testuser" +) + +func (otu *OverflowTestUtils) CreateUserAPI(user *models.User) *httptest.ResponseRecorder { + json, _ := json.Marshal(user) + req, _ := http.NewRequest("POST", "/user", bytes.NewBuffer(json)) + req.Header.Set("Content-Type", "application/json") + return otu.ExecuteRequest(req) +} + +func (otu *OverflowTestUtils) GetUserAPI(user *models.User) *httptest.ResponseRecorder { + req, _ := http.NewRequest("GET", fmt.Sprintf("/user/%s", *user.Addr), nil) + return otu.ExecuteRequest(req) +} + +func (otu *OverflowTestUtils) UpdateUserAPI(user *models.User) *httptest.ResponseRecorder { + json, _ := json.Marshal(user) + req, _ := http.NewRequest("PUT", "/user", bytes.NewBuffer(json)) + req.Header.Set("Content-Type", "application/json") + return otu.ExecuteRequest(req) +} + +func (otu *OverflowTestUtils) GenerateUserStruct(signer string) *models.User { + account, _ := otu.O.State.Accounts().ByName(fmt.Sprintf("emulator-%s", signer)) + address := fmt.Sprintf("0x%s", account.Address().String()) + + return &models.User{ + Addr: &address, + Profile_image: &dummyProfileImage, + Name: &dummyName, + Bio: &dummyBio, + Twitter: &dummyTwitter, + Discord: &dummyDiscord, + Instagram: &dummyInstagram, + } +} + +func (otu *OverflowTestUtils) GenerateUserPayload(signer string, user models.User) *models.User { + payload := user + timestamp := fmt.Sprint(time.Now().UnixNano() / int64(time.Millisecond)) + compositeSignatures := otu.GenerateCompositeSignatures(signer, timestamp) + payload.Timestamp = ×tamp + payload.Composite_signatures = compositeSignatures + return &payload +} + +func (otu *OverflowTestUtils) GenerateFailUserStruct() *models.User { + nonExistentAccount := "0x696969" + + return &models.User{ + Addr: &nonExistentAccount, + Profile_image: &dummyProfileImage, + Name: &dummyName, + Bio: &dummyBio, + Twitter: &dummyTwitter, + Discord: &dummyDiscord, + Instagram: &dummyInstagram, + } +} diff --git a/backend/tests/user_test.go b/backend/tests/user_test.go new file mode 100644 index 000000000..ba1bedab3 --- /dev/null +++ b/backend/tests/user_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/DapperCollectives/CAST/backend/main/models" + "github.com/stretchr/testify/assert" +) + +func TestUser(t *testing.T) { + clearTable("users") + + t.Run("should be able to create a user", func(t *testing.T) { + userStruct := otu.GenerateUserStruct("account") + payload := otu.GenerateUserPayload("account", *userStruct) + response := otu.CreateUserAPI(payload) + checkResponseCode(t, http.StatusCreated, response.Code) + + var created models.User + json.Unmarshal(response.Body.Bytes(), &created) + + assert.Equal(t, *userStruct.Addr, *created.Addr) + }) + + t.Run("should be able to get a user", func(t *testing.T) { + userStruct := otu.GenerateUserStruct("account") + response := otu.GetUserAPI(userStruct) + checkResponseCode(t, http.StatusOK, response.Code) + + var created models.User + json.Unmarshal(response.Body.Bytes(), &created) + + req, _ := http.NewRequest("GET", fmt.Sprintf("/user/%s", *created.Addr), nil) + response = otu.ExecuteRequest(req) + + var user models.User + json.Unmarshal(response.Body.Bytes(), &user) + + assert.Equal(t, *created.Addr, *user.Addr) + }) + + t.Run("should be able to update a user", func(t *testing.T) { + userStruct := otu.GenerateUserStruct("account") + payload := otu.GenerateUserPayload("account", *userStruct) + response := otu.CreateUserAPI(payload) + checkResponseCode(t, http.StatusCreated, response.Code) + + var toUpdate models.User + json.Unmarshal(response.Body.Bytes(), &toUpdate) + + updatedName := "Updated Name" + + toUpdate.Name = &updatedName + updatePayload := otu.GenerateUserPayload("account", toUpdate) + response = otu.UpdateUserAPI(updatePayload) + + var updated models.User + json.Unmarshal(response.Body.Bytes(), &updated) + + assert.Equal(t, *toUpdate.Name, *updated.Name) + }) + + t.Run("should throw an error when getting a user that doesn't exist", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/user/0x69", nil) + response := otu.ExecuteRequest(req) + + checkResponseCode(t, http.StatusNotFound, response.Code) + + var m map[string]string + json.Unmarshal(response.Body.Bytes(), &m) + + assert.Equal(t, "User not found", m["message"]) + }) + + t.Run("should throw an error when updating a user that doesn't exist", func(t *testing.T) { + userStruct := otu.GenerateFailUserStruct() + payload, _ := json.Marshal(userStruct) + req, _ := http.NewRequest("PUT", "/user", bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + response := otu.ExecuteRequest(req) + + checkResponseCode(t, http.StatusNotFound, response.Code) + + var m map[string]string + json.Unmarshal(response.Body.Bytes(), &m) + + assert.Equal(t, "User not found", m["message"]) + }) +}