Compare commits
No commits in common. "7add2af0c250c1dcf153f49b5a93588360193e0a" and "dfe00f835931ad88f6ce1420abda7a67647d19a0" have entirely different histories.
7add2af0c2
...
dfe00f8359
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
*.env
|
||||
tmp
|
||||
public
|
4
Makefile
4
Makefile
@ -5,8 +5,8 @@ migrateup:
|
||||
migrate -path db/migrations -database "${DB_TYPE}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}_test?sslmode=disable" -verbose up
|
||||
|
||||
migratedown:
|
||||
migrate -path db/migrations -database "${DB_TYPE}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" -verbose down $n
|
||||
migrate -path db/migrations -database "${DB_TYPE}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}_test?sslmode=disable" -verbose down $n
|
||||
migrate -path db/migrations -database "${DB_TYPE}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable" -verbose down $N
|
||||
migrate -path db/migrations -database "${DB_TYPE}://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}_test?sslmode=disable" -verbose down $N
|
||||
|
||||
seed:
|
||||
./import_csv.sh
|
||||
|
@ -1,44 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type APIValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func validationErrorMsg(field string, param string, tag string) string {
|
||||
switch tag {
|
||||
case "min":
|
||||
return fmt.Sprintf("%s character min %s", field, param)
|
||||
case "required":
|
||||
return fmt.Sprintf("%s is %s", field, tag)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", field, tag)
|
||||
}
|
||||
}
|
||||
|
||||
func ValidationErrorResponse(err error) gin.H {
|
||||
var ves validator.ValidationErrors
|
||||
var temp []APIValidationError
|
||||
if errors.As(err, &ves) {
|
||||
out := make([]APIValidationError, len(ves))
|
||||
for i, ve := range ves {
|
||||
out[i] = APIValidationError{ve.Field(), validationErrorMsg(ve.Field(), ve.Param(), ve.ActualTag())}
|
||||
}
|
||||
temp = out
|
||||
}
|
||||
return gin.H{
|
||||
"message": "Validation error",
|
||||
"errors": temp,
|
||||
}
|
||||
}
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func ErrorResponse(err error, msg string) gin.H {
|
||||
return gin.H{
|
||||
|
120
api/location.go
120
api/location.go
@ -1,120 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
db "git.nochill.in/nochill/hiling_go/db/sqlc"
|
||||
"git.nochill.in/nochill/hiling_go/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type createLocationReq struct {
|
||||
Address string `form:"address" binding:"required"`
|
||||
Name string `form:"name" binding:"required"`
|
||||
SubmittedBy int32 `form:"submitted_by" binding:"required,number"`
|
||||
RegencyID int16 `form:"regency_id" binding:"required,number"`
|
||||
GoogleMapsLink string `form:"google_maps_link"`
|
||||
}
|
||||
|
||||
func (server *Server) createLocation(ctx *gin.Context) {
|
||||
var req createLocationReq
|
||||
var imgPath string
|
||||
|
||||
var thumbnail, _ = ctx.FormFile("thumbnail")
|
||||
|
||||
if err := ctx.Bind(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
if thumbnail != nil {
|
||||
img := thumbnail
|
||||
fileExt := filepath.Ext(img.Filename)
|
||||
now := time.Now()
|
||||
dir := fmt.Sprintf("public/upload/images/locations/%s/thumbnail", req.Name)
|
||||
osFilename := fmt.Sprintf("%s%s%s", util.RandomString(5), fmt.Sprintf("%v", now.Unix()), fileExt)
|
||||
imgPath = fmt.Sprintf("%s%s", dir, osFilename)
|
||||
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
os.Mkdir(dir, 0775)
|
||||
}
|
||||
|
||||
if err := ctx.SaveUploadedFile(img, imgPath); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Error while try to save thumbnail image"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
arg := db.CreateLocationParams{
|
||||
Address: req.Address,
|
||||
Name: req.Name,
|
||||
SubmittedBy: req.SubmittedBy,
|
||||
RegencyID: req.RegencyID,
|
||||
GoogleMapsLink: sql.NullString{Valid: len(req.GoogleMapsLink) > 0, String: req.GoogleMapsLink},
|
||||
}
|
||||
|
||||
err := server.Store.CreateLocation(ctx, arg)
|
||||
if err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok {
|
||||
ctx.JSON(http.StatusConflict, ErrorResponse(err, fmt.Sprintf("Something went wrong, code: %s", pqErr.Code.Name())))
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Writer.WriteHeader(http.StatusOK)
|
||||
|
||||
}
|
||||
|
||||
type getListLocationsReq struct {
|
||||
Page int32 `form:"page" binding:"required,min=1"`
|
||||
PageSize int32 `form:"page_size" binding:"required,min=5"`
|
||||
}
|
||||
|
||||
func (server *Server) getListLocations(ctx *gin.Context) {
|
||||
var req getListLocationsReq
|
||||
if err := ctx.ShouldBindQuery(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
arg := db.GetListLocationsParams{
|
||||
Limit: req.PageSize,
|
||||
Offset: (req.Page - 1) * req.PageSize,
|
||||
}
|
||||
|
||||
locations, err := server.Store.GetListLocations(ctx, arg)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, locations)
|
||||
}
|
||||
|
||||
type getLocationReq struct {
|
||||
ID int32 `uri:"location_id" binding:"required"`
|
||||
}
|
||||
|
||||
func (server *Server) getLocation(ctx *gin.Context) {
|
||||
var req getLocationReq
|
||||
if err := ctx.ShouldBindUri(&req); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
|
||||
return
|
||||
}
|
||||
|
||||
location, err := server.Store.GetLocation(ctx, req.ID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, location)
|
||||
}
|
@ -10,9 +10,9 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Config util.Config
|
||||
Store db.Store
|
||||
TokenMaker token.Maker
|
||||
config util.Config
|
||||
store db.Store
|
||||
tokenMaker token.Maker
|
||||
Router *gin.Engine
|
||||
}
|
||||
|
||||
@ -23,9 +23,9 @@ func NewServer(config util.Config, store db.Store) (*Server, error) {
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
Config: config,
|
||||
Store: store,
|
||||
TokenMaker: tokenMaker,
|
||||
config: config,
|
||||
store: store,
|
||||
tokenMaker: tokenMaker,
|
||||
}
|
||||
|
||||
server.getRoutes()
|
||||
@ -37,11 +37,6 @@ func (server *Server) getRoutes() {
|
||||
|
||||
router.POST("/user/signup", server.createUser)
|
||||
|
||||
// LOCATION
|
||||
router.POST("/locations", server.createLocation)
|
||||
router.GET("/locations", server.getListLocations)
|
||||
router.GET("/location/:location_id", server.getLocation)
|
||||
|
||||
server.Router = router
|
||||
}
|
||||
|
||||
|
@ -1,31 +0,0 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
db "git.nochill.in/nochill/hiling_go/db/sqlc"
|
||||
"git.nochill.in/nochill/hiling_go/util"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestGetListLocationsAPI(t *testing.T) {
|
||||
|
||||
}
|
||||
|
||||
func TestCreateLocationAPI(t *testing.T) {
|
||||
_ = db.CreateLocationParams{
|
||||
Address: util.RandomString(10),
|
||||
Name: util.RandomString(10),
|
||||
SubmittedBy: int32(util.RandomInt(0, 10)),
|
||||
RegencyID: 1305,
|
||||
GoogleMapsLink: sql.NullString{Valid: true, String: util.RandomString(10)},
|
||||
}
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
// store := mockdb.MockStore
|
||||
})
|
||||
}
|
@ -22,10 +22,10 @@ func TestSignupAPI(t *testing.T) {
|
||||
defer ctrl.Finish()
|
||||
|
||||
store := mockdb.NewMockStore(ctrl)
|
||||
// store.EXPECT().
|
||||
// CreateUser(gomock.Any(), gomock.Any()).
|
||||
// Times(1).
|
||||
// Return(user, nil)
|
||||
store.EXPECT().
|
||||
CreateUser(gomock.Any(), gomock.Any()).
|
||||
Times(1).
|
||||
Return(user, nil)
|
||||
|
||||
server := newTestServer(t, store)
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -56,5 +56,5 @@ func createUser(t *testing.T) (user db.User, password string) {
|
||||
Password: hashedPassword,
|
||||
}
|
||||
|
||||
return user, passw
|
||||
return
|
||||
}
|
||||
|
56
api/user.go
56
api/user.go
@ -2,12 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
db "git.nochill.in/nochill/hiling_go/db/sqlc"
|
||||
"git.nochill.in/nochill/hiling_go/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
@ -23,19 +25,40 @@ type createUserResponse struct {
|
||||
BannedAt sql.NullTime `json:"banned_at"`
|
||||
BannedUntil sql.NullTime `json:"banned_until"`
|
||||
BanReason string `json:"ban_reason"`
|
||||
IsPermaban bool `json:"is_permaban"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
IsCritics bool `json:"is_critics"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IsPermaban sql.NullBool `json:"is_permaban"`
|
||||
IsAdmin sql.NullBool `json:"is_admin"`
|
||||
IsCritics sql.NullBool `json:"is_critics"`
|
||||
IsVerified sql.NullBool `json:"is_verified"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ApiError struct {
|
||||
Field string
|
||||
Msg string
|
||||
}
|
||||
|
||||
func msgForTag(field string, param string, tag string) string {
|
||||
switch tag {
|
||||
case "min":
|
||||
return fmt.Sprintf("%s character min %s", field, param)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", field, tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) createUser(ctx *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := ctx.ShouldBindJSON(&req); err != nil {
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
|
||||
var ve validator.ValidationErrors
|
||||
if errors.As(err, &ve) {
|
||||
out := make([]ApiError, len(ve))
|
||||
for i, fe := range ve {
|
||||
out[i] = ApiError{fe.Field(), msgForTag(fe.Field(), fe.Param(), fe.Tag())}
|
||||
}
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"errors": out})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -51,13 +74,12 @@ func (server *Server) createUser(ctx *gin.Context) {
|
||||
Password: hashedPassword,
|
||||
}
|
||||
|
||||
user, err := server.Store.CreateUser(ctx, arg)
|
||||
user, err := server.store.CreateUser(ctx, arg)
|
||||
if err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok {
|
||||
switch pqErr.Code.Name() {
|
||||
case "foreign_key_violation", "unique_violation":
|
||||
ctx.JSON(http.StatusConflict, ErrorResponse(err, "Username already used"))
|
||||
return
|
||||
ctx.JSON(http.StatusConflict, ErrorResponse(err, "Something went wrong while try to save"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,12 +94,12 @@ func (server *Server) createUser(ctx *gin.Context) {
|
||||
BannedAt: sql.NullTime{Valid: user.BannedAt.Valid, Time: user.BannedAt.Time},
|
||||
BannedUntil: sql.NullTime{Valid: user.BannedUntil.Valid, Time: user.BannedUntil.Time},
|
||||
BanReason: user.BanReason.String,
|
||||
IsPermaban: user.IsPermaban.Bool,
|
||||
IsAdmin: user.IsAdmin.Bool,
|
||||
IsCritics: user.IsCritics.Bool,
|
||||
IsVerified: user.IsVerified.Bool,
|
||||
CreatedAt: user.CreatedAt.Time,
|
||||
UpdatedAt: user.UpdatedAt.Time,
|
||||
IsPermaban: user.IsPermaban,
|
||||
IsAdmin: user.IsAdmin,
|
||||
IsCritics: user.IsCritics,
|
||||
IsVerified: user.IsVerified,
|
||||
CreatedAt: sql.NullTime{Valid: true, Time: user.CreatedAt.Time},
|
||||
UpdatedAt: sql.NullTime{Valid: true, Time: user.UpdatedAt.Time},
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, res)
|
||||
|
@ -1,10 +1,10 @@
|
||||
id,address,name,submitted_by,thumbnail,regency_id,google_maps_link,approved_by
|
||||
1#Jalan Raya Beside the bridge Ubud#Murni’s Warung#1#https://cdn.discordapp.com/attachments/743422487882104837/1150972798320267304/image.png#5104#https://www.google.com/maps/place/Murni's+Warung/@-8.5048696,115.2553417,19z/data=!4m6!3m5!1s0x2dd23d3e0ffaa071:0xf2fa69b4cb211e41!8m2!3d-8.5051184!4d115.2547196!16s%2Fg%2F1tdsmcq7?entry=ttu#1
|
||||
2#Jl.Taman Wijaya Kusuma Ps. Baru Kecamatan Sawah Besar#Masjid Istiqlal#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/04/11/31/33/istiqlal-mosque-mesjid.jpg?w=500&h=-1&s=1#3173#https://www.google.com/maps/place/Masjid+Istiqlal/@-6.1703155,106.8308434,19z/data=!4m15!1m7!3m6!1s0x2e69f5ce68b5e01d:0xcafaf042d5840c6c!2sMasjid+Istiqlal!8m2!3d-6.17017!4d106.83139!16zL20vMDRzam1q!3m6!1s0x2e69f5ce68b5e01d:0xcafaf042d5840c6c!8m2!3d-6.17017!4d106.83139!15sCg9tYXNqaWQgaXN0aXFsYWxaESIPbWFzamlkIGlzdGlxbGFskgEGbW9zcXVl4AEA!16zL20vMDRzam1q?entry=ttu#1
|
||||
3#Jl. Mayjend Sungkono no. 89#Hotel Ciputra World Surabaya#1#https://lh5.googleusercontent.com/p/AF1QipOvHDO-M6riRoqBrWU3MskhwL_bue8JmN9faq7Q=w500-h500-k-no#3578#https://www.google.com/maps/place/Ciputra+World+Hotel+Surabaya/@-7.2923061,112.7191552,15z/data=!4m2!3m1!1s0x0:0x736a9c49dcc2ac42?sa=X&ved=2ahUKEwjJlbf8gqSBAxWtzzgGHUIkBFYQ_BJ6BAgVEAA&ved=2ahUKEwjJlbf8gqSBAxWtzzgGHUIkBFYQ_BJ6BAgjEAc#1
|
||||
4#Jl. Taman Safari No.101 . B Cibeureum Kec. Cisarua#Club Huis#1#https://media-cdn.tripadvisor.com/media/photo-o/0d/6a/5d/63/our-peaceful-backyard.jpg#3201#https://www.google.com/maps/place/Club+Huis/@-6.7027857,106.9453741,17z/data=!3m1!4b1!4m6!3m5!1s0x2e69b679d7a09e01:0xf9fc2df396f09977!8m2!3d-6.7027857!4d106.947949!16s%2Fg%2F11c57lh8ky?entry=ttu#1
|
||||
5#Desa Tambakrejo Kecamatan Sumbermanjing Wetan#Pulau Sempu#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/11/3b/06/a5/pulau-sempu.jpg?w=500&h=-1&s=1#3507#https://www.google.com/maps/place/Pulau+Sempu/@-8.446621,112.6746143,14z/data=!3m1!4b1!4m6!3m5!1s0x2dd60120edbc901f:0x8efd89687a308993!8m2!3d-8.4428564!4d112.6973355!16s%2Fm%2F0r8k540?entry=ttu#1
|
||||
6#Jl. Bukit Golf I BSD Sektor VI Lengkong Karya Kec. Serpong Utara#Damai Indah Golf#1#https://lh3.googleusercontent.com/p/AF1QipN5Z-0J6vIfIO6gqPO0z5HDWlNKqp0t816XIJPS=s680-w500-h500#3674#https://www.google.com/maps/place/Damai+Indah+Golf+-+BSD+Course/@-6.2815644,106.6496566,17z/data=!3m1!4b1!4m6!3m5!1s0x2e69fb152983d973:0x89e58e219f8b93ef!8m2!3d-6.2815644!4d106.6522315!16s%2Fg%2F11c54c9r94?entry=ttu#1
|
||||
7#Jl. P. Mangkubumi No.72A Cokrodiningratan Kec. Jetis#Hotel Tentrem Yogyakarta#1#https://cdn.discordapp.com/attachments/743422487882104837/1150987888553623653/image.png#3471#https://www.google.com/maps?q=Hotel+Tentrem+Yogyakarta&source=lmns&entry=mc&bih=1115&biw=2124&hl=en-US&sa=X&ved=2ahUKEwjjl-HHiKSBAxUu5jgGHTU3BiwQ0pQJKAJ6BAgBEAY#1
|
||||
8#Moluo Kec.Kwandang#Pulau Saronde Gorontalo#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/0d/ec/58/21/saronde-island-a-place.jpg?w=700&h=-1&s=1#7505#https://www.google.com/maps/place/Pulau+Saronde+Gorontalo/@0.9263376,122.8613201,17z/data=!3m1!4b1!4m6!3m5!1s0x32795bf34dff4467:0xa8beb2a832ae8176!8m2!3d0.9263376!4d122.863895!16s%2Fg%2F11l241cc1d?hl=id&entry=ttu#1
|
||||
9#Dusun Katiet Desa Bosua Kecamatan Sipora#Pantai Katiet#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/1a/d7/fe/f4/mentawai-islands.jpg?w=500&h=-1&s=1#1301#https://www.google.com/maps/place/Katiet,+Bosua,+Sipora+Selatan,+Mentawai+Islands+Regency,+West+Sumatra/@-2.375793,99.848187,15z/data=!3m1!4b1!4m6!3m5!1s0x2fd27efa8363912f:0x8c9c19bd76cba179!8m2!3d-2.375793!4d99.848187!16s%2Fg%2F1tcwz0mt?hl=en-US&entry=ttu#1
|
||||
id#address#name#submitted_by#thumbnail#regency_id#google_maps_link
|
||||
1#Jalan Raya Beside the bridge Ubud#Murni’s Warung#1#https://cdn.discordapp.com/attachments/743422487882104837/1150972798320267304/image.png#5104#https://www.google.com/maps/place/Murni's+Warung/@-8.5048696,115.2553417,19z/data=!4m6!3m5!1s0x2dd23d3e0ffaa071:0xf2fa69b4cb211e41!8m2!3d-8.5051184!4d115.2547196!16s%2Fg%2F1tdsmcq7?entry=ttu
|
||||
2#Jl.Taman Wijaya Kusuma Ps. Baru Kecamatan Sawah Besar#Masjid Istiqlal#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/04/11/31/33/istiqlal-mosque-mesjid.jpg?w=500&h=-1&s=1#3173#https://www.google.com/maps/place/Masjid+Istiqlal/@-6.1703155,106.8308434,19z/data=!4m15!1m7!3m6!1s0x2e69f5ce68b5e01d:0xcafaf042d5840c6c!2sMasjid+Istiqlal!8m2!3d-6.17017!4d106.83139!16zL20vMDRzam1q!3m6!1s0x2e69f5ce68b5e01d:0xcafaf042d5840c6c!8m2!3d-6.17017!4d106.83139!15sCg9tYXNqaWQgaXN0aXFsYWxaESIPbWFzamlkIGlzdGlxbGFskgEGbW9zcXVl4AEA!16zL20vMDRzam1q?entry=ttu
|
||||
3#Jl. Mayjend Sungkono no. 89#Hotel Ciputra World Surabaya#1#https://lh5.googleusercontent.com/p/AF1QipOvHDO-M6riRoqBrWU3MskhwL_bue8JmN9faq7Q=w500-h500-k-no#3578#https://www.google.com/maps/place/Ciputra+World+Hotel+Surabaya/@-7.2923061,112.7191552,15z/data=!4m2!3m1!1s0x0:0x736a9c49dcc2ac42?sa=X&ved=2ahUKEwjJlbf8gqSBAxWtzzgGHUIkBFYQ_BJ6BAgVEAA&ved=2ahUKEwjJlbf8gqSBAxWtzzgGHUIkBFYQ_BJ6BAgjEAc
|
||||
4#Jl. Taman Safari No.101 . B Cibeureum Kec. Cisarua#Club Huis#1#https://media-cdn.tripadvisor.com/media/photo-o/0d/6a/5d/63/our-peaceful-backyard.jpg#3201#https://www.google.com/maps/place/Club+Huis/@-6.7027857,106.9453741,17z/data=!3m1!4b1!4m6!3m5!1s0x2e69b679d7a09e01:0xf9fc2df396f09977!8m2!3d-6.7027857!4d106.947949!16s%2Fg%2F11c57lh8ky?entry=ttu
|
||||
5#Desa Tambakrejo Kecamatan Sumbermanjing Wetan#Pulau Sempu#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/11/3b/06/a5/pulau-sempu.jpg?w=500&h=-1&s=1#3507#https://www.google.com/maps/place/Pulau+Sempu/@-8.446621,112.6746143,14z/data=!3m1!4b1!4m6!3m5!1s0x2dd60120edbc901f:0x8efd89687a308993!8m2!3d-8.4428564!4d112.6973355!16s%2Fm%2F0r8k540?entry=ttu
|
||||
6#Jl. Bukit Golf I BSD Sektor VI Lengkong Karya Kec. Serpong Utara#Damai Indah Golf#1#https://lh3.googleusercontent.com/p/AF1QipN5Z-0J6vIfIO6gqPO0z5HDWlNKqp0t816XIJPS=s680-w500-h500#3674#https://www.google.com/maps/place/Damai+Indah+Golf+-+BSD+Course/@-6.2815644,106.6496566,17z/data=!3m1!4b1!4m6!3m5!1s0x2e69fb152983d973:0x89e58e219f8b93ef!8m2!3d-6.2815644!4d106.6522315!16s%2Fg%2F11c54c9r94?entry=ttu
|
||||
7#Jl. P. Mangkubumi No.72A Cokrodiningratan Kec. Jetis#Hotel Tentrem Yogyakarta#1#https://cdn.discordapp.com/attachments/743422487882104837/1150987888553623653/image.png#3471#https://www.google.com/maps?q=Hotel+Tentrem+Yogyakarta&source=lmns&entry=mc&bih=1115&biw=2124&hl=en-US&sa=X&ved=2ahUKEwjjl-HHiKSBAxUu5jgGHTU3BiwQ0pQJKAJ6BAgBEAY
|
||||
8#Moluo Kec.Kwandang#Pulau Saronde Gorontalo#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/0d/ec/58/21/saronde-island-a-place.jpg?w=700&h=-1&s=1#7505#https://www.google.com/maps/place/Pulau+Saronde+Gorontalo/@0.9263376,122.8613201,17z/data=!3m1!4b1!4m6!3m5!1s0x32795bf34dff4467:0xa8beb2a832ae8176!8m2!3d0.9263376!4d122.863895!16s%2Fg%2F11l241cc1d?hl=id&entry=ttu
|
||||
9#Dusun Katiet Desa Bosua Kecamatan Sipora#Pantai Katiet#1#https://dynamic-media-cdn.tripadvisor.com/media/photo-o/1a/d7/fe/f4/mentawai-islands.jpg?w=500&h=-1&s=1#1301#https://www.google.com/maps/place/Katiet,+Bosua,+Sipora+Selatan,+Mentawai+Islands+Regency,+West+Sumatra/@-2.375793,99.848187,15z/data=!3m1!4b1!4m6!3m5!1s0x2fd27efa8363912f:0x8c9c19bd76cba179!8m2!3d-2.375793!4d99.848187!16s%2Fg%2F1tcwz0mt?hl=en-US&entry=ttu
|
|
@ -12,7 +12,6 @@ CREATE TABLE users(
|
||||
"is_admin" boolean,
|
||||
"is_critics" boolean,
|
||||
"is_verified" boolean,
|
||||
"is_active" boolean,
|
||||
"social_media" jsonb,
|
||||
"created_at" timestamp default(now()),
|
||||
"updated_at" timestamp default(now())
|
||||
@ -81,7 +80,7 @@ CREATE TABLE locations(
|
||||
"google_maps_link" varchar,
|
||||
"submitted_by" integer references "users"("id") not null,
|
||||
"total_visited" integer,
|
||||
"thumbnail" varchar,
|
||||
"thumbnail" varchar not null,
|
||||
"regency_id" smallint references "regencies"("id") not null,
|
||||
"is_deleted" boolean,
|
||||
"created_at" timestamp default(now()),
|
||||
|
@ -1,2 +0,0 @@
|
||||
ALTER TABLE locations DROP COLUMN IF EXISTS approved_by;
|
||||
ALTER TABLE locations DROP COLUMN IF EXISTS approved_at;
|
@ -1,2 +0,0 @@
|
||||
ALTER TABLE locations ADD column approved_by int references "users"("id");
|
||||
ALTER TABLE locations ADD column approved_at timestamp;
|
@ -35,20 +35,6 @@ func (m *MockStore) EXPECT() *MockStoreMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CreateLocation mocks base method.
|
||||
func (m *MockStore) CreateLocation(arg0 context.Context, arg1 db.CreateLocationParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateLocation", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// CreateLocation indicates an expected call of CreateLocation.
|
||||
func (mr *MockStoreMockRecorder) CreateLocation(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateLocation", reflect.TypeOf((*MockStore)(nil).CreateLocation), arg0, arg1)
|
||||
}
|
||||
|
||||
// CreateUser mocks base method.
|
||||
func (m *MockStore) CreateUser(arg0 context.Context, arg1 db.CreateUserParams) (db.User, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@ -64,36 +50,6 @@ func (mr *MockStoreMockRecorder) CreateUser(arg0, arg1 interface{}) *gomock.Call
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUser", reflect.TypeOf((*MockStore)(nil).CreateUser), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetListLocations mocks base method.
|
||||
func (m *MockStore) GetListLocations(arg0 context.Context, arg1 db.GetListLocationsParams) ([]db.Location, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetListLocations", arg0, arg1)
|
||||
ret0, _ := ret[0].([]db.Location)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetListLocations indicates an expected call of GetListLocations.
|
||||
func (mr *MockStoreMockRecorder) GetListLocations(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetListLocations", reflect.TypeOf((*MockStore)(nil).GetListLocations), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetLocation mocks base method.
|
||||
func (m *MockStore) GetLocation(arg0 context.Context, arg1 int32) (db.Location, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetLocation", arg0, arg1)
|
||||
ret0, _ := ret[0].(db.Location)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetLocation indicates an expected call of GetLocation.
|
||||
func (mr *MockStoreMockRecorder) GetLocation(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocation", reflect.TypeOf((*MockStore)(nil).GetLocation), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdatePassword mocks base method.
|
||||
func (m *MockStore) UpdatePassword(arg0 context.Context, arg1 db.UpdatePasswordParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -1,19 +0,0 @@
|
||||
-- name: GetListLocations :many
|
||||
SELECT * FROM locations
|
||||
LIMIT $1
|
||||
OFFSET $2;
|
||||
|
||||
-- name: GetLocation :one
|
||||
SELECT * FROM locations
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: CreateLocation :exec
|
||||
INSERT INTO locations(
|
||||
address,
|
||||
name,
|
||||
submitted_by,
|
||||
regency_id,
|
||||
google_maps_link
|
||||
) values (
|
||||
$1, $2, $3, $4, $5
|
||||
);
|
@ -1,116 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.20.0
|
||||
// source: locations.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const createLocation = `-- name: CreateLocation :exec
|
||||
INSERT INTO locations(
|
||||
address,
|
||||
name,
|
||||
submitted_by,
|
||||
regency_id,
|
||||
google_maps_link
|
||||
) values (
|
||||
$1, $2, $3, $4, $5
|
||||
)
|
||||
`
|
||||
|
||||
type CreateLocationParams struct {
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
SubmittedBy int32 `json:"submitted_by"`
|
||||
RegencyID int16 `json:"regency_id"`
|
||||
GoogleMapsLink sql.NullString `json:"google_maps_link"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateLocation(ctx context.Context, arg CreateLocationParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createLocation,
|
||||
arg.Address,
|
||||
arg.Name,
|
||||
arg.SubmittedBy,
|
||||
arg.RegencyID,
|
||||
arg.GoogleMapsLink,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getListLocations = `-- name: GetListLocations :many
|
||||
SELECT id, address, name, google_maps_link, submitted_by, total_visited, thumbnail, regency_id, is_deleted, created_at, updated_at, approved_by, approved_at FROM locations
|
||||
LIMIT $1
|
||||
OFFSET $2
|
||||
`
|
||||
|
||||
type GetListLocationsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetListLocations(ctx context.Context, arg GetListLocationsParams) ([]Location, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getListLocations, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Location{}
|
||||
for rows.Next() {
|
||||
var i Location
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Address,
|
||||
&i.Name,
|
||||
&i.GoogleMapsLink,
|
||||
&i.SubmittedBy,
|
||||
&i.TotalVisited,
|
||||
&i.Thumbnail,
|
||||
&i.RegencyID,
|
||||
&i.IsDeleted,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLocation = `-- name: GetLocation :one
|
||||
SELECT id, address, name, google_maps_link, submitted_by, total_visited, thumbnail, regency_id, is_deleted, created_at, updated_at, approved_by, approved_at FROM locations
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLocation(ctx context.Context, id int32) (Location, error) {
|
||||
row := q.db.QueryRowContext(ctx, getLocation, id)
|
||||
var i Location
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Address,
|
||||
&i.Name,
|
||||
&i.GoogleMapsLink,
|
||||
&i.SubmittedBy,
|
||||
&i.TotalVisited,
|
||||
&i.Thumbnail,
|
||||
&i.RegencyID,
|
||||
&i.IsDeleted,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
@ -126,8 +126,8 @@ type Comment struct {
|
||||
|
||||
type Location struct {
|
||||
ID int32 `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Name string `json:"name"`
|
||||
Address sql.NullString `json:"address"`
|
||||
Name sql.NullString `json:"name"`
|
||||
GoogleMapsLink sql.NullString `json:"google_maps_link"`
|
||||
SubmittedBy int32 `json:"submitted_by"`
|
||||
TotalVisited sql.NullInt32 `json:"total_visited"`
|
||||
@ -136,8 +136,6 @@ type Location struct {
|
||||
IsDeleted sql.NullBool `json:"is_deleted"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ApprovedBy sql.NullInt32 `json:"approved_by"`
|
||||
ApprovedAt sql.NullTime `json:"approved_at"`
|
||||
}
|
||||
|
||||
type LocationImage struct {
|
||||
@ -206,7 +204,6 @@ type User struct {
|
||||
IsAdmin sql.NullBool `json:"is_admin"`
|
||||
IsCritics sql.NullBool `json:"is_critics"`
|
||||
IsVerified sql.NullBool `json:"is_verified"`
|
||||
IsActive sql.NullBool `json:"is_active"`
|
||||
SocialMedia pqtype.NullRawMessage `json:"social_media"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
|
@ -9,10 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateLocation(ctx context.Context, arg CreateLocationParams) error
|
||||
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
|
||||
GetListLocations(ctx context.Context, arg GetListLocationsParams) ([]Location, error)
|
||||
GetLocation(ctx context.Context, id int32) (Location, error)
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error)
|
||||
}
|
||||
|
@ -1,41 +0,0 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
db "git.nochill.in/nochill/hiling_go/db/sqlc"
|
||||
"git.nochill.in/nochill/hiling_go/util"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetLocationsList(t *testing.T) {
|
||||
arg := db.GetListLocationsParams{
|
||||
Limit: 10,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
locations, err := testQueries.GetListLocations(context.Background(), arg)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, locations)
|
||||
}
|
||||
|
||||
func TestGetLocation(t *testing.T) {
|
||||
location, err := testQueries.GetLocation(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, location)
|
||||
}
|
||||
|
||||
func TestCreateLocation(t *testing.T) {
|
||||
arg := db.CreateLocationParams{
|
||||
Address: util.RandomString(12),
|
||||
Name: util.RandomString(10),
|
||||
SubmittedBy: 1,
|
||||
RegencyID: 1305,
|
||||
GoogleMapsLink: sql.NullString{Valid: true, String: util.RandomString(10)},
|
||||
}
|
||||
|
||||
err := testQueries.CreateLocation(context.Background(), arg)
|
||||
require.NoError(t, err)
|
||||
}
|
@ -11,7 +11,7 @@ import (
|
||||
|
||||
func TestCreateUser(t *testing.T) {
|
||||
arg := db.CreateUserParams{
|
||||
Username: util.RandomString(7),
|
||||
Username: util.RandomString(10),
|
||||
Password: util.RandomString(10),
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ INSERT INTO users (
|
||||
username,
|
||||
password
|
||||
) VALUES ($1, $2)
|
||||
RETURNING id, email, username, password, avatar_picture, google_sign_in_payload, banned_at, banned_until, ban_reason, is_permaban, is_admin, is_critics, is_verified, is_active, social_media, created_at, updated_at
|
||||
RETURNING id, email, username, password, avatar_picture, google_sign_in_payload, banned_at, banned_until, ban_reason, is_permaban, is_admin, is_critics, is_verified, social_media, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
@ -40,7 +40,6 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
||||
&i.IsAdmin,
|
||||
&i.IsCritics,
|
||||
&i.IsVerified,
|
||||
&i.IsActive,
|
||||
&i.SocialMedia,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
@ -72,7 +71,7 @@ SET
|
||||
avatar_picture = COALESCE($3, avatar_picture)
|
||||
WHERE
|
||||
id = $4
|
||||
RETURNING id, email, username, password, avatar_picture, google_sign_in_payload, banned_at, banned_until, ban_reason, is_permaban, is_admin, is_critics, is_verified, is_active, social_media, created_at, updated_at
|
||||
RETURNING id, email, username, password, avatar_picture, google_sign_in_payload, banned_at, banned_until, ban_reason, is_permaban, is_admin, is_critics, is_verified, social_media, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
@ -104,7 +103,6 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e
|
||||
&i.IsAdmin,
|
||||
&i.IsCritics,
|
||||
&i.IsVerified,
|
||||
&i.IsActive,
|
||||
&i.SocialMedia,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
|
@ -5,26 +5,12 @@ sudo -u postgres psql \
|
||||
-c '\copy regions(id, region_name) FROM '"'/tmp/regions.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy provinces(id, province_name, region_id) FROM '"'/tmp/provinsi.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy regencies(id, province_id, regency_name) FROM '"'/tmp/kabupaten.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy locations(id,address,name,submitted_by,thumbnail,regency_id,google_maps_link,approved_by) FROM '"'/tmp/locations.csv'"' DELIMITER '"'#'"' CSV HEADER;' \
|
||||
-c '\copy locations(id,address,name,submitted_by,thumbnail,regency_id,google_maps_link) FROM '"'/tmp/locations.csv'"' DELIMITER '"'#'"' CSV HEADER;' \
|
||||
-d hiling_dev &&
|
||||
sudo -u postgres psql \
|
||||
-c '\copy users(id,username,password) FROM '"'/tmp/user.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy regions(id, region_name) FROM '"'/tmp/regions.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy provinces(id, province_name, region_id) FROM '"'/tmp/provinsi.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy regencies(id, province_id, regency_name) FROM '"'/tmp/kabupaten.csv'"' DELIMITER '"','"' CSV HEADER;' \
|
||||
-c '\copy locations(id,address,name,submitted_by,thumbnail,regency_id,google_maps_link,approved_by) FROM '"'/tmp/locations.csv'"' DELIMITER '"'#'"' CSV HEADER;' \
|
||||
-c '\copy locations(id,address,name,submitted_by,thumbnail,regency_id,google_maps_link) FROM '"'/tmp/locations.csv'"' DELIMITER '"'#'"' CSV HEADER;' \
|
||||
-d hiling_dev_test
|
||||
|
||||
# FIXING SEQUENCES AFTER SEEDING
|
||||
sudo -u postgres psql \
|
||||
-c 'SELECT setval('"'locations_id_seq'"',(SELECT GREATEST(MAX(id)+1,nextval('"'locations_id_seq'"'))-1 FROM locations))' \
|
||||
-d hiling_dev_test &&
|
||||
sudo -u postgres psql \
|
||||
-c 'SELECT setval('"'users_id_seq'"',(SELECT GREATEST(MAX(id)+1,nextval('"'users_id_seq'"'))-1 FROM users))' \
|
||||
-d hiling_dev_test
|
||||
sudo -u postgres psql \
|
||||
-c 'SELECT setval('"'locations_id_seq'"',(SELECT GREATEST(MAX(id)+1,nextval('"'locations_id_seq'"'))-1 FROM locations))' \
|
||||
-d hiling_dev &&
|
||||
sudo -u postgres psql \
|
||||
-c 'SELECT setval('"'users_id_seq'"',(SELECT GREATEST(MAX(id)+1,nextval('"'users_id_seq'"'))-1 FROM users))' \
|
||||
-d hiling_dev
|
||||
|
15
notes
15
notes
@ -43,19 +43,4 @@ https://github.com/gin-gonic/gin/issues/430 (using middleware)
|
||||
tbh i'd raher use like a wrapper instead of middleware but we'll see
|
||||
|
||||
|
||||
##########################################################################################
|
||||
|
||||
|
||||
##########################################################################################
|
||||
|
||||
WITHOUT aUTHORIZATION ->
|
||||
- AUTH
|
||||
- GET LOCATIONS
|
||||
- POST,PATCH,GET COMMENTS
|
||||
- GET REVIEWS
|
||||
- POST, REVIEWS ?
|
||||
|
||||
THE REST SHOUDL HAVE AUTHORIVAOZOIANITON
|
||||
|
||||
|
||||
##########################################################################################
|
@ -1,6 +1,3 @@
|
||||
when user open hiling site, for the firstime frontend or web send an user ip address to server(why ? so they don't have multiple accounts on 1 device, ofc we can limit the thing but i think having 1 account per device is good practice so user not abusing) after that they gonna the page, if they navigate to the homepage they gonna see the index page
|
||||
from there user can click the locations, login, see another user reviews or any other acitivites
|
||||
user can submit user review/rating without login since the system already save user ip address but the user cant see about their reviews, likes, or saved location, user have to login first to see about user reviews, likes, saved location, following etc etc
|
||||
|
||||
|
||||
OK, so if user want to submit a location it has go through process where moderator approved the submition then it's live, and it gotta mention people who contributes, mf are neanderthals
|
Loading…
Reference in New Issue
Block a user