diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bd7eca --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Dependency directories (remove the comment below if you are using dep) +# vendor/ + +# Compiled Object files, Static and Dynamic libs (remove the comment below if you are using go modules) +*.o +*.a +*.lo +*.la +*.os +*.so +*.so.* +*.dylib +*.test +*.prof +*.exe + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d9dcae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.22.3-bullseye +# Set the Current Working Directory inside the container +WORKDIR /app + +# Copy the source from the current directory to the Working Directory inside the container +COPY . . + +# Download all the dependencies +RUN go mod download + +# Install the package +RUN go build -o main . + +# This container exposes port 8080 to the outside world +EXPOSE 8080 + +# Run the executable +CMD ["./main"] \ No newline at end of file diff --git a/README.md b/README.md index 3e00463..8849acf 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,163 @@ -# GlobalWebIndex Engineering Challenge +# User Favorites API -## Introduction +This is a sample Go application that provides an API for managing user favorites. Users can register, login, and manage their favorite assets (charts, insights, and audiences). The API uses JWT for authentication. -This challenge is designed to give you the opportunity to demonstrate your abilities as a software engineer and specifically your knowledge of the Go language. +## Project Structure -On the surface the challenge is trivial to solve, however you should choose to add features or capabilities which you feel demonstrate your skills and knowledge the best. For example, you could choose to optimise for performance and concurrency, you could choose to add a robust security layer or ensure your application is highly available. Or all of these. +``` +user-favorites-api/ +├── Dockerfile +├── docker-compose.yml +├── go.mod +├── go.sum +├── handlers/ +│ └── handlers.go +├── main.go +├── models/ +│ └── models.go +├── router/ +│ └── router.go +├── store/ +│ └── store.go +├── middleware/ +│ └── jwt.go +├── db_scripts/ +│ └── dbsetup.sql +└── README.md +``` -Of course, usually we would choose to solve any given requirement with the simplest possible solution, however that is not the spirit of this challenge. +## Setup -## Challenge +### Prerequisites -Let's say that in GWI platform all of our users have access to a huge list of assets. We want our users to have a peronal list of favourites, meaning assets that favourite or “star” so that they have them in their frontpage dashboard for quick access. An asset can be one the following -* Chart (that has a small title, axes titles and data) -* Insight (a small piece of text that provides some insight into a topic, e.g. "40% of millenials spend more than 3hours on social media daily") -* Audience (which is a series of characteristics, for that exercise lets focus on gender (Male, Female), birth country, age groups, hours spent daily on social media, number of purchases last month) -e.g. Males from 24-35 that spent more than 3 hours on social media daily. +- Docker +- Docker Compose -Build a web server which has some endpoint to receive a user id and return a list of all the user’s favourites. Also we want endpoints that would add an asset to favourites, remove it, or edit its description. Assets obviously can share some common attributes (like their description) but they also have completely different structure and data. It’s up to you to decide the structure and we are not looking for something overly complex here (especially for the cases of audiences). There is no need to have/deploy/create an actual database although we would like to discuss about storage options and data representations. +### Running the Application -Note that users have no limit on how many assets they want on their favourites so your service will need to provide a reasonable response time. +1. Ensure you have Docker and Docker Compose installed. +2. Build and start the application using Docker Compose: -A working server application with functional API is required, along with a clear readme.md. Useful and passing tests would be also be viewed favourably +```sh +docker-compose up --build --remove-orphans +``` -It is appreciated, though not required, if a Dockerfile is included. +This command will build the Go application image, start the PostgreSQL database, and then start the Go application. The application will be available at `http://localhost:8080`, and the PostgreSQL database will be available at `localhost:5432` with the credentials specified. -## Submission +## API Endpoints -Just create a fork from the current repo and send it to us! +### User Registration -Good luck, potential colleague! +- **URL:** `/register` +- **Method:** `POST` +- **Request Body:** + +```json +{ + "username": "john_doe", + "email": "john@example.com", + "password": "password123" +} +``` + +- **Sample Request:** + +```sh +curl -X POST http://localhost:8080/register -d '{"username":"john_doe","email":"john@example.com","password":"password123"}' -H "Content-Type: application/json" +``` + +### User Login + +- **URL:** `/login` +- **Method:** `POST` +- **Request Body:** + +```json +{ + "username": "john_doe", + "password": "password123" +} +``` + +- **Sample Request:** + +```sh +curl -X POST http://localhost:8080/login -d '{"username":"john_doe","password":"password123"}' -H "Content-Type: application/json" +``` + +- **Response:** + +```json +{ + "token": "your_jwt_token_here" +} +``` + +### Get User Favorites + +- **URL:** `/api/favorites/{userID}` +- **Method:** `GET` +- **Headers:** `Authorization: Bearer your_jwt_token_here` +- **Sample Request:** + +```sh +curl -H "Authorization: Bearer your_jwt_token_here" http://localhost:8080/api/favorites/1 +``` + +### Add Favorite + +- **URL:** `/api/favorites/{userID}` +- **Method:** `POST` +- **Headers:** `Authorization: Bearer your_jwt_token_here` +- **Request Body:** + +```json +{ + "id": "1", + "type": "chart", + "description": "Test Chart", + "data": "Sample Data" +} +``` + +- **Sample Request:** + +```sh +curl -X POST -H "Authorization: Bearer your_jwt_token_here" -d '{"id":"1","type":"chart","description":"Test Chart","data":"Sample Data"}' -H "Content-Type: application/json" http://localhost:8080/api/favorites/1 +``` + +### Remove Favorite + +- **URL:** `/api/favorites/{userID}/{assetID}` +- **Method:** `DELETE` +- **Headers:** `Authorization: Bearer your_jwt_token_here` +- **Sample Request:** + +```sh +curl -X DELETE -H "Authorization: Bearer your_jwt_token_here" http://localhost:8080/api/favorites/1/1 +``` + +### Edit Favorite + +- **URL:** `/api/favorites/{userID}/{assetID}` +- **Method:** `PUT` +- **Headers:** `Authorization: Bearer your_jwt_token_here` +- **Request Body:** + +```json +{ + "description": "Updated Chart Description", + "data": "Updated Sample Data" +} +``` + +- **Sample Request:** + +```sh +curl -X PUT -H "Authorization: Bearer your_jwt_token_here" -d '{"description":"Updated Chart Description","data":"Updated Sample Data"}' -H "Content-Type: application/json" http://localhost:8080/api/favorites/1/1 +``` + +## Environment Variables + +- `DATABASE_URL`: The URL of the PostgreSQL database. Example: `postgresql://user_favorites_user:mysecretpassword@db:5432/user_favorites_db` +- `SECRET_KEY`: The secret key used for signing JWT tokens. It should be a long and random string. Example: `my_super_secret_key_123` diff --git a/db_scripts/dbsetup.sql b/db_scripts/dbsetup.sql new file mode 100644 index 0000000..94d81c8 --- /dev/null +++ b/db_scripts/dbsetup.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL, + password TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS assets ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL, + asset_id VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + description TEXT NOT NULL, + data TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f13dd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + db: + image: postgres:latest + environment: + POSTGRES_DB: user_favorites_db + POSTGRES_USER: user_favorites_user + POSTGRES_PASSWORD: mysecretpassword + volumes: + - db_data:/var/lib/postgresql/data + - ./db_scripts:/docker-entrypoint-initdb.d + ports: + - "5432:5432" + + app: + build: . + environment: + DATABASE_URL: postgresql://user_favorites_user:mysecretpassword@db:5432/user_favorites_db + SECRET_KEY: mysecretkey + ports: + - "8080:8080" + depends_on: + - db + +volumes: + db_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a466f86 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module user-favorites-api + +go 1.22.3 + +require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gorilla/mux v1.8.1 + github.com/jackc/pgx/v5 v5.6.0 + golang.org/x/crypto v0.23.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1e7ac4 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/handlers.go b/handlers/handlers.go new file mode 100644 index 0000000..248bc3c --- /dev/null +++ b/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "github.com/gorilla/mux" + "user-favorites-api/models" + "user-favorites-api/store" +) + +type Handler struct { + Store *store.Store +} + + +func (h *Handler) RegisterUser(w http.ResponseWriter, r *http.Request) { + var user models.User + if err := json.NewDecoder(r.Body).Decode(&user); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := h.Store.RegisterUser(&user); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + var credentials models.Credentials + if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + token, err := h.Store.AuthenticateUser(&credentials) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + json.NewEncoder(w).Encode(map[string]string{"token": token}) +} + +func (h *Handler) GetFavorites(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userID"] + + userFav, err := h.Store.GetUserFavorites(userID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(userFav) +} + +func (h *Handler) AddFavorite(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userID"] + + var asset models.Asset + if err := json.NewDecoder(r.Body).Decode(&asset); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := h.Store.AddFavorite(userID, asset); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *Handler) RemoveFavorite(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userID"] + assetID := vars["assetID"] + + if err := h.Store.RemoveFavorite(userID, assetID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) EditFavorite(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userID"] + assetID := vars["assetID"] + + var updatedAsset models.Asset + if err := json.NewDecoder(r.Body).Decode(&updatedAsset); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := h.Store.EditFavorite(userID, assetID, updatedAsset); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5334577 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "log" + "net/http" + "os" + "user-favorites-api/router" + "user-favorites-api/store" +) + +func main() { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + log.Fatal("DATABASE_URL environment variable is required") + } + + store, err := store.NewStore(dbURL) + if err != nil { + log.Fatalf("failed to connect to database: %v", err) + } + defer store.DB.Close() + + r := router.NewRouter(store) + http.ListenAndServe(":8080", r) +} diff --git a/middleware/jwt.go b/middleware/jwt.go new file mode 100644 index 0000000..200eebf --- /dev/null +++ b/middleware/jwt.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + "user-favorites-api/store" + + "github.com/dgrijalva/jwt-go" +) + +func extractAssetIDFromPath(r *http.Request) string { + // Get the path from the request URL + path := r.URL.Path + + // Split the path by '/' to get the individual segments + segments := strings.Split(path, "/") + + // The asset ID is typically the last segment in the path + assetID := segments[len(segments)-1] + + return assetID +} + +func JWTAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "authorization header required", http.StatusUnauthorized) + return + } + + bearerToken := strings.Split(authHeader, " ") + if len(bearerToken) != 2 { + http.Error(w, "invalid authorization header format", http.StatusUnauthorized) + return + } + + tokenStr := bearerToken[1] + claims := &store.Claims{} + + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + return store.JwtKey, nil + }) + + if err != nil || !token.Valid { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), "username", claims.Username) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..7b157b6 --- /dev/null +++ b/models/models.go @@ -0,0 +1,65 @@ +package models + +import "encoding/json" + +type AssetType string + +const ( + Chart AssetType = "chart" + Insight AssetType = "insight" + Audience AssetType = "audience" +) + +type Asset struct { + ID string `json:"id"` + Type AssetType `json:"type"` + Description string `json:"description"` + Data string `json:"data"` +} + +type UserFavorites struct { + UserID string `json:"userId"` + Assets []Asset `json:"assets"` +} + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"-"` + CreatedAt string `json:"created_at"` +} + +type Credentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// MarshalJSON customizes JSON encoding to exclude the Password field. +func (u User) MarshalJSON() ([]byte, error) { + type Alias User + return json.Marshal(&struct { + Password string `json:"password,omitempty"` + *Alias + }{ + Password: "", + Alias: (*Alias)(&u), + }) +} + +// UnmarshalJSON customizes JSON decoding to include the Password field. +func (u *User) UnmarshalJSON(data []byte) error { + type Alias User + aux := &struct { + Password string `json:"password"` + *Alias + }{ + Alias: (*Alias)(u), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + u.Password = aux.Password + return nil +} + diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..5c5fb0f --- /dev/null +++ b/router/router.go @@ -0,0 +1,27 @@ +package router + +import ( + "user-favorites-api/handlers" + "user-favorites-api/middleware" + "user-favorites-api/store" + + "github.com/gorilla/mux" +) + +func NewRouter(store *store.Store) *mux.Router { + h := &handlers.Handler{Store: store} + r := mux.NewRouter() + + r.HandleFunc("/register", h.RegisterUser).Methods("POST") + r.HandleFunc("/login", h.Login).Methods("POST") + + api := r.PathPrefix("/api").Subrouter() + api.Use(middleware.JWTAuth) + api.HandleFunc("/favorites/{userID}", h.GetFavorites).Methods("GET") + api.HandleFunc("/favorites/{userID}", h.AddFavorite).Methods("POST") + api.HandleFunc("/favorites/{userID}/{assetID}", h.RemoveFavorite).Methods("DELETE") + api.HandleFunc("/favorites/{userID}/{assetID}", h.EditFavorite).Methods("PUT") + + + return r +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..ac4ab8c --- /dev/null +++ b/store/store.go @@ -0,0 +1,114 @@ +package store + +import ( + "context" + "fmt" + "os" + "time" + "user-favorites-api/models" + + "github.com/dgrijalva/jwt-go" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +var JwtKey = []byte(os.Getenv("SECRET_KEY")) + +type Claims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +type Store struct { + DB *pgxpool.Pool +} + +func NewStore(connectionString string) (*Store, error) { + config, err := pgxpool.ParseConfig(connectionString) + if err != nil { + return nil, err + } + db, err := pgxpool.NewWithConfig(context.Background(), config) + if err != nil { + return nil, err + } + + return &Store{DB: db}, nil +} + + +func (s *Store) RegisterUser(user *models.User) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + + user.Password = string(hashedPassword) + _, err = s.DB.Exec(context.Background(), "INSERT INTO users (username, email, password) VALUES ($1, $2, $3)", + user.Username, user.Email, user.Password) + return err +} + +func (s *Store) AuthenticateUser(credentials *models.Credentials) (string, error) { + var user models.User + err := s.DB.QueryRow(context.Background(), "SELECT id, password FROM users WHERE username=$1", credentials.Username).Scan(&user.ID, &user.Password) + if err != nil { + return "", fmt.Errorf("user not found") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(credentials.Password)); err != nil { + return "", fmt.Errorf("invalid credentials") + } + + expirationTime := time.Now().Add(24 * time.Hour) + claims := &Claims{ + Username: credentials.Username, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: expirationTime.Unix(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(JwtKey) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (s *Store) GetUserFavorites(userID string) (*models.UserFavorites, error) { + userFavorites := &models.UserFavorites{UserID: userID} + rows, err := s.DB.Query(context.Background(), "SELECT asset_id, type, description, data FROM assets WHERE user_id=$1", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var asset models.Asset + if err := rows.Scan(&asset.ID, &asset.Type, &asset.Description, &asset.Data); err != nil { + return nil, err + } + userFavorites.Assets = append(userFavorites.Assets, asset) + } + + return userFavorites, nil +} + +func (s *Store) AddFavorite(userID string, asset models.Asset) error { + _, err := s.DB.Exec(context.Background(), "INSERT INTO assets (user_id, asset_id, type, description, data) VALUES ($1, $2, $3, $4, $5)", + userID, asset.ID, asset.Type, asset.Description, asset.Data) + return err +} + +func (s *Store) RemoveFavorite(userID, assetID string) error { + _, err := s.DB.Exec(context.Background(), "DELETE FROM assets WHERE user_id=$1 AND asset_id=$2", userID, assetID) + return err +} + +func (s *Store) EditFavorite(userID, assetID string, asset models.Asset) error { + _, err := s.DB.Exec(context.Background(), "UPDATE assets SET description=$1, data=$2 WHERE user_id=$3 AND asset_id=$4", + asset.Description, asset.Data, userID, assetID) + return err +}