diff --git a/.env b/.env index 6dcd355..bc5aa8c 100644 --- a/.env +++ b/.env @@ -56,4 +56,5 @@ PDF_SERVER_URL=http://localhost:8000 FILE_MAAL_PL_USER=git_operator FILE_MAAL_PL_PASSWORD=1FnwqcEgIUjQHjt1 -IMAGE_PREFIX=https://www.naluconcept.com # remove prefix to serv them from same host as presta \ No newline at end of file +IMAGE_PREFIX=https://www.naluconcept.com # remove prefix to serv them from same host as presta +CORS_ORGIN=https://www.naluconcept.com \ No newline at end of file diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..6dcd355 --- /dev/null +++ b/.env_example @@ -0,0 +1,59 @@ +SERVER_PORT=3000 +SERVER_HOST=0.0.0.0 + +# Database Configuration +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=Maal12345678 +DB_NAME=nalu +PROJECT_NAME=nalu_b2b +DB_SERVICE_NAME=nalu_b2b +DB_SSLMODE=disable + +# App COnfig +APP_NAME="B2b Management System" +APP_VERSION=2.1.0 +APP_ENVIRONMENT=development + +# JWT Configuration +AUTH_JWT_SECRET=5c020e6ed3d8d6e67e5804d67c83c4bd5ae474df749af6d63d8f20e7e2ba29b3 +AUTH_JWT_EXPIRATION=86400 +AUTH_REFRESH_EXPIRATION=604800 + +# Meili search +MEILISEARCH_URL=http://localhost:7700 +MEILISEARCH_API_KEY=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 + +# OpenAI +OPENAI_KEY=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 + +# Google Translate Client +GOOGLE_APPLICATION_CREDENTIALS=./google-cred.json +GOOGLE_CLOUD_PROJECT_ID=translation-343517 + +# Google OAuth2 +OAUTH_GOOGLE_CLIENT_ID=331979954218-9vrpe08oqhhcgj6bvu6d4lds0dt630m9.apps.googleusercontent.com +OAUTH_GOOGLE_CLIENT_SECRET=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 +OAUTH_GOOGLE_REDIRECT_URL=http://localhost:3000/api/v1/public/auth/google/callback + +# Email Configuration (SMTP) +# Set EMAIL_ENABLED=true to require email verification +EMAIL_ENABLED=true +EMAIL_SMTP_HOST=mail.ma-al.com +EMAIL_SMTP_PORT=587 +EMAIL_SMTP_USER=test@ma-al.com +EMAIL_SMTP_PASSWORD=maal12345678 +EMAIL_FROM=test@ma-al.com +EMAIL_FROM_NAME=Gitea Manager +EMAIL_ADMIN=goc_marek@ma-al.pl + +I18N_LANGS=en,pl,cs + +PDF_SERVER_URL=http://localhost:8000 + + +FILE_MAAL_PL_USER=git_operator +FILE_MAAL_PL_PASSWORD=1FnwqcEgIUjQHjt1 + +IMAGE_PREFIX=https://www.naluconcept.com # remove prefix to serv them from same host as presta \ No newline at end of file diff --git a/app/api/openapi.json b/app/api/openapi.json index 37420fe..403e09e 100644 --- a/app/api/openapi.json +++ b/app/api/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "b2b API", - "description": "Authentication, user management, and repository time tracking API", + "description": "Authentication, user management, and repository time tracking API\n\n## Filter Operators\nAll filter parameters support the following operators by adding a suffix to the parameter name:\n\n| Suffix | Operator | Example |\n|--------|----------|---------|\n| `_eq` | equals | `product_id_eq=12` |\n| `_neq` | not equals | `active_neq=0` |\n| `_gt` | greater than | `price_gt=100` |\n| `_gte` | greater than or equal | `price_gte=10` |\n| `_lt` | less than | `price_lt=500` |\n| `_lte` | less than or equal | `price_lte=500` |\n| `_in` | IN (comma-separated) | `product_id_in=1,2,3,4` |\n\n## Special Filters\n\n| Parameter | Example | Description |\n|-----------|---------|-------------|\n| `name=~text` | `name=~gold` | LIKE search (case-insensitive partial match) |\n| `price=[min,max]` | `price=[100,500]` | BETWEEN range (inclusive) |\n| `sort=field,direction` | `sort=price,desc` | ORDER BY clause (direction: asc/desc, default: desc) |\n| `p` | `p=1` | Page number (1-based, default: 1) |\n| `elems` | `elems=30` | Elements per page (max: 100, default: 30) |", "version": "1.0.0", "contact": { "name": "API Support", @@ -11,8 +11,8 @@ }, "servers": [ { - "url": "http://localhost:3000", - "description": "Development server" + "url": "/", + "description": "Development server on same host" } ], "tags": [ @@ -48,17 +48,13 @@ "name": "Locale", "description": "Locale selection endpoints (under /api/v1/restricted/langs-and-countries, requires authentication)" }, - { - "name": "Repo", - "description": "Repository time tracking data endpoints (under /api/v1/restricted/repo, requires authentication)" - }, - { - "name": "Admin", - "description": "Admin-only endpoints" - }, { "name": "Settings", "description": "Application settings and configuration endpoints" + }, + { + "name": "Carts", + "description": "Shopping cart management endpoints (under /api/v1/restricted/carts, requires authentication)" } ], "paths": { @@ -629,6 +625,41 @@ } } }, + "/api/v1/public/auth/update-choice": { + "post": { + "tags": ["Auth"], + "summary": "Update JWT token choice", + "description": "Updates the user's JWT token preference or refreshes the token. Requires authentication.", + "operationId": "updateChoice", + "security": [ + { + "CookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Token choice updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/api/v1/public/auth/google": { "get": { "tags": ["Auth"], @@ -733,292 +764,6 @@ } } }, - "/api/v1/restricted/repo/get-repos": { - "get": { - "tags": ["Repo"], - "summary": "Get accessible repositories", - "description": "Returns a list of repository IDs that the authenticated user has access to.", - "operationId": "getRepos", - "security": [ - { - "CookieAuth": [] - } - ], - "responses": { - "200": { - "description": "List of repository IDs", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "uint" - }, - "example": [1, 2, 5] - } - } - } - }, - "400": { - "description": "Invalid user session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/v1/restricted/repo/get-years": { - "get": { - "tags": ["Repo"], - "summary": "Get available years for a repository", - "description": "Returns a list of years for which tracked time data exists in the given repository. User must have access to the repository.", - "operationId": "getYears", - "security": [ - { - "CookieAuth": [] - } - ], - "parameters": [ - { - "name": "repoID", - "in": "query", - "description": "Repository ID", - "required": true, - "schema": { - "type": "integer", - "format": "uint" - } - } - ], - "responses": { - "200": { - "description": "List of years with tracked time data", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "integer", - "format": "uint" - }, - "example": [2023, 2024, 2025] - } - } - } - }, - "400": { - "description": "Invalid repoID parameter or user does not have access to the repository", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/v1/restricted/repo/get-quarters": { - "get": { - "tags": ["Repo"], - "summary": "Get quarterly time data for a repository", - "description": "Returns time tracked per quarter for the given repository and year. All 4 quarters are returned; quarters with no data have time=0. User must have access to the repository.", - "operationId": "getQuarters", - "security": [ - { - "CookieAuth": [] - } - ], - "parameters": [ - { - "name": "repoID", - "in": "query", - "description": "Repository ID", - "required": true, - "schema": { - "type": "integer", - "format": "uint" - } - }, - { - "name": "year", - "in": "query", - "description": "Year to retrieve quarterly data for", - "required": true, - "schema": { - "type": "integer", - "format": "uint", - "example": 2024 - } - } - ], - "responses": { - "200": { - "description": "Quarterly time data", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuarterData" - } - } - } - } - }, - "400": { - "description": "Invalid repoID or year parameter, or user does not have access to the repository", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/api/v1/restricted/repo/get-issues": { - "get": { - "tags": ["Repo"], - "summary": "Get issues with time summaries", - "description": "Returns a paginated list of issues with time tracking summaries for the given repository, year, and quarter. User must have access to the repository.", - "operationId": "getIssues", - "security": [ - { - "CookieAuth": [] - } - ], - "parameters": [ - { - "name": "repoID", - "in": "query", - "description": "Repository ID", - "required": true, - "schema": { - "type": "integer", - "format": "uint" - } - }, - { - "name": "year", - "in": "query", - "description": "Year to filter issues by", - "required": true, - "schema": { - "type": "integer", - "format": "uint", - "example": 2024 - } - }, - { - "name": "quarter", - "in": "query", - "description": "Quarter number (1-4) to filter issues by", - "required": true, - "schema": { - "type": "integer", - "format": "uint", - "minimum": 1, - "maximum": 4, - "example": 2 - } - }, - { - "name": "page_number", - "in": "query", - "description": "Page number for pagination (1-based)", - "required": true, - "schema": { - "type": "integer", - "format": "uint", - "example": 1 - } - }, - { - "name": "elements_per_page", - "in": "query", - "description": "Number of items per page", - "required": true, - "schema": { - "type": "integer", - "format": "uint", - "example": 30 - } - } - ], - "responses": { - "200": { - "description": "Paginated list of issues with time summaries", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedIssues" - } - } - } - }, - "400": { - "description": "Invalid parameters or user does not have access to the repository", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "401": { - "description": "Not authenticated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, "/api/v1/settings": { "get": { "tags": ["Settings"], @@ -1043,7 +788,7 @@ "get": { "tags": ["Products"], "summary": "Get product listing", - "description": "Returns a paginated list of products with their basic information. Requires authentication.", + "description": "Returns a paginated list of products with their basic information. Supports filtering via query parameters with operators (e.g., product_id_eq=12, name=~wałek). Use sort parameter for ordering. Requires authentication.", "operationId": "getProductListing", "security": [ { @@ -1064,12 +809,76 @@ { "name": "elems", "in": "query", - "description": "Number of items per page", + "description": "Number of items per page (max: 100, default: 30)", "required": false, "schema": { "type": "integer", "default": 30 } + }, + { + "name": "sort", + "in": "query", + "description": "Sort field and direction. Format: field,direction (e.g., 'product_id,desc' or 'name,asc')", + "required": false, + "schema": { + "type": "string", + "example": "product_id,desc" + } + }, + { + "name": "product_id", + "in": "query", + "description": "Filter by product ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "name", + "in": "query", + "description": "Filter by product name using LIKE (case-insensitive). Use ~ prefix for partial match (e.g., '~wałek')", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "reference", + "in": "query", + "description": "Filter by product reference using LIKE (case-insensitive)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id_category", + "in": "query", + "description": "Filter by category ID. Use suffix _eq, _gt, _gte, _lt, _lte, _neq, _in for operators.", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "category_name", + "in": "query", + "description": "Filter by category name using LIKE (case-insensitive)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "quantity", + "in": "query", + "description": "Filter by quantity. Use suffix _eq, _gt, _gte, _lt, _lte for operators.", + "required": false, + "schema": { + "type": "integer" + } } ], "responses": { @@ -1702,6 +1511,280 @@ } } } + }, + "/api/v1/restricted/carts/add-new-cart": { + "get": { + "tags": ["Carts"], + "summary": "Create a new cart", + "description": "Creates a new shopping cart for the authenticated user. Requires authentication.", + "operationId": "addNewCart", + "security": [ + { + "CookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Cart created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/restricted/carts/change-cart-name": { + "get": { + "tags": ["Carts"], + "summary": "Change cart name", + "description": "Updates the name of an existing cart. Requires authentication.", + "operationId": "changeCartName", + "security": [ + { + "CookieAuth": [] + } + ], + "parameters": [ + { + "name": "cart_id", + "in": "query", + "description": "ID of the cart to rename", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "new_name", + "in": "query", + "description": "New name for the cart", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cart name updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/restricted/carts/retrieve-carts-info": { + "get": { + "tags": ["Carts"], + "summary": "Retrieve all carts info", + "description": "Returns information about all carts belonging to the authenticated user. Requires authentication.", + "operationId": "retrieveCartsInfo", + "security": [ + { + "CookieAuth": [] + } + ], + "responses": { + "200": { + "description": "Carts info retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/restricted/carts/retrieve-cart": { + "get": { + "tags": ["Carts"], + "summary": "Retrieve cart details", + "description": "Returns detailed contents of a specific cart. Requires authentication.", + "operationId": "retrieveCart", + "security": [ + { + "CookieAuth": [] + } + ], + "parameters": [ + { + "name": "cart_id", + "in": "query", + "description": "ID of the cart to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Cart retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/restricted/carts/add-product-to-cart": { + "get": { + "tags": ["Carts"], + "summary": "Add product to cart", + "description": "Adds a product to the specified cart. Requires authentication.", + "operationId": "addProductToCart", + "security": [ + { + "CookieAuth": [] + } + ], + "parameters": [ + { + "name": "cart_id", + "in": "query", + "description": "ID of the cart to add product to", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "product_id", + "in": "query", + "description": "ID of the product to add", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "product_attribute_id", + "in": "query", + "description": "ID of the product attribute (optional, for product variants)", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "amount", + "in": "query", + "description": "Quantity to add", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Product added to cart successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Not authenticated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "components": { diff --git a/app/config/config.go b/app/config/config.go index e5be538..f68d2fd 100644 --- a/app/config/config.go +++ b/app/config/config.go @@ -23,6 +23,7 @@ type Config struct { Pdf PdfPrinter GoogleTranslate GoogleTranslateConfig Image ImageConfig + Cors CorsConfig } type I18n struct { @@ -33,6 +34,10 @@ type ServerConfig struct { Host string `env:"SERVER_HOST,0.0.0.0"` } +type CorsConfig struct { + Origins []string `env:"CORS_ORGIN"` +} + type ImageConfig struct { ImagePrefix string `env:"IMAGE_PREFIX"` } @@ -176,6 +181,11 @@ func load() *Config { if err != nil { slog.Error("not possible to load env variables for google translate : ", err.Error(), "") } + + err = loadEnv(&cfg.Cors) + if err != nil { + slog.Error("not possible to load env variables for google translate : ", err.Error(), "") + } return cfg } diff --git a/app/delivery/middleware/cors.go b/app/delivery/middleware/cors.go index e52024b..f876769 100644 --- a/app/delivery/middleware/cors.go +++ b/app/delivery/middleware/cors.go @@ -1,11 +1,23 @@ package middleware -import "github.com/gofiber/fiber/v3" +import ( + "strings" + + "git.ma-al.com/goc_daniel/b2b/app/config" + "github.com/gofiber/fiber/v3" +) // CORSMiddleware creates CORS middleware func CORSMiddleware() fiber.Handler { return func(c fiber.Ctx) error { - c.Set("Access-Control-Allow-Origin", "*") + + if strings.Contains(c.Get("Host"), "localhost") { + c.Set("Access-Control-Allow-Origin", c.Get("Host")) + } else { + origins := strings.Join(config.Get().Cors.Origins, ",") + c.Set("Access-Control-Allow-Origin", origins) + } + c.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") diff --git a/app/delivery/web/api/restricted/listProducts.go b/app/delivery/web/api/restricted/listProducts.go index 2478cc5..962fd07 100644 --- a/app/delivery/web/api/restricted/listProducts.go +++ b/app/delivery/web/api/restricted/listProducts.go @@ -2,11 +2,10 @@ package restricted import ( "git.ma-al.com/goc_daniel/b2b/app/config" + "git.ma-al.com/goc_daniel/b2b/app/model" "git.ma-al.com/goc_daniel/b2b/app/service/listProductsService" "git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "git.ma-al.com/goc_daniel/b2b/app/utils/nullable" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" - "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" "git.ma-al.com/goc_daniel/b2b/app/utils/query/query_params" "git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" @@ -37,7 +36,7 @@ func ListProductsHandlerRoutes(r fiber.Router) fiber.Router { } func (h *ListProductsHandler) GetListing(c fiber.Ctx) error { - paging, filters, err := ParseProductFilters(c) + paging, filters, err := query_params.ParseFilters[model.Product](c, columnMappingGetListing) if err != nil { return c.Status(responseErrors.GetErrorStatus(err)). JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, err))) @@ -58,14 +57,11 @@ func (h *ListProductsHandler) GetListing(c fiber.Ctx) error { return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK))) } -var columnMapping map[string]string = map[string]string{} - -func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) { - var p find.Paging - fl := filters.NewFiltersList() - - pageNum, pageElems := query_params.ParsePagination(c) - p = find.Paging{Page: pageNum, Elements: pageElems} - - return p, &fl, nil +var columnMappingGetListing map[string]string = map[string]string{ + "product_id": "ps.id_product", + "name": "pl.name", + "reference": "ps.reference", + "category_name": "cl.name", + "id_category": "cp.id_category", + "quantity": "sa.quantity", } diff --git a/app/delivery/web/general/swagger.go b/app/delivery/web/general/swagger.go index 9e0876c..e0a5cc0 100644 --- a/app/delivery/web/general/swagger.go +++ b/app/delivery/web/general/swagger.go @@ -47,6 +47,11 @@ var swaggerHTML = ` url: "/openapi.json", dom_id: '#swagger-ui', deepLinking: true, + withCredentials: true, + "servers": [ + { "url": "http://localhost:3000" }, + { "url": "http://localhost:5173" } + ], presets: [ SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset diff --git a/app/model/countries.go b/app/model/countries.go index 61972d2..dccbe8b 100644 --- a/app/model/countries.go +++ b/app/model/countries.go @@ -9,3 +9,16 @@ type Country struct { CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"` CurrencyName string `gorm:"column:name" json:"currency_name"` } + +func (Country) TableName() string { + return "b2b_countries" +} + +type PSCountry struct { + ID uint `gorm:"primaryKey;column:id_country" json:"id"` + CurrencyID uint `gorm:"column:id_currency" json:"currency_id"` +} + +func (PSCountry) TableName() string { + return "ps_country" +} diff --git a/app/model/product.go b/app/model/product.go index 36b0d70..7705238 100644 --- a/app/model/product.go +++ b/app/model/product.go @@ -62,11 +62,14 @@ type Product struct { DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"` } type ProductInList struct { - ProductID uint `gorm:"column:product_id;primaryKey" json:"product_id" form:"product_id"` - Name string `gorm:"column:name" json:"name" form:"name"` - ImageID string `gorm:"column:id_image"` - LinkRewrite string `gorm:"column:link_rewrite"` - Active uint `gorm:"column:active" json:"active" form:"active"` + ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"` + Name string `gorm:"column:name" json:"name" form:"name"` + LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"` + ImageLink string `gorm:"column:image_link" json:"image_link"` + CategoryName string `gorm:"column:category_name" json:"category_name" form:"category_name"` + Reference string `gorm:"column:reference" json:"reference"` + VariantsNumber uint `gorm:"column:variants_number" json:"variants_number"` + Quantity int64 `gorm:"column:quantity" json:"quantity"` } type ProductFilters struct { diff --git a/app/repos/categoriesRepo/categoriesRepo.go b/app/repos/categoriesRepo/categoriesRepo.go index d420187..bc378c1 100644 --- a/app/repos/categoriesRepo/categoriesRepo.go +++ b/app/repos/categoriesRepo/categoriesRepo.go @@ -30,12 +30,22 @@ func (repo *CategoriesRepo) GetAllCategories(id_lang uint) ([]model.ScannedCateg ps_category.is_root_category AS is_root_category, ps_category_lang.link_rewrite AS link_rewrite, ps_lang.iso_code AS iso_code - FROM ps_category - LEFT JOIN ps_category_lang ON ps_category_lang.id_category = ps_category.id_category AND ps_category_lang.id_shop = ? AND ps_category_lang.id_lang = ? - LEFT JOIN ps_category_shop ON ps_category_shop.id_category = ps_category.id_category AND ps_category_shop.id_shop = ? - JOIN ps_lang ON ps_lang.id_lang = ps_category_lang.id_lang - `, - constdata.SHOP_ID, id_lang, constdata.SHOP_ID). + `). + Joins(` + LEFT JOIN ps_category_lang + ON ps_category_lang.id_category = ps_category.id_category + AND ps_category_lang.id_shop = ? + AND ps_category_lang.id_lang = ? + `, constdata.SHOP_ID, id_lang). + Joins(` + LEFT JOIN ps_category_shop + ON ps_category_shop.id_category = ps_category.id_category + AND ps_category_shop.id_shop = ? + `, constdata.SHOP_ID). + Joins(` + JOIN ps_lang + ON ps_lang.id_lang = ps_category_lang.id_lang + `). Scan(&allCategories).Error return allCategories, err diff --git a/app/repos/listProductsRepo/listProductsRepo.go b/app/repos/listProductsRepo/listProductsRepo.go index bd9fda3..32739d9 100644 --- a/app/repos/listProductsRepo/listProductsRepo.go +++ b/app/repos/listProductsRepo/listProductsRepo.go @@ -1,10 +1,8 @@ package listProductsRepo import ( - "git.ma-al.com/goc_daniel/b2b/app/config" "git.ma-al.com/goc_daniel/b2b/app/db" "git.ma-al.com/goc_daniel/b2b/app/model" - constdata "git.ma-al.com/goc_daniel/b2b/app/utils/const_data" "git.ma-al.com/goc_daniel/b2b/app/utils/query/filters" "git.ma-al.com/goc_daniel/b2b/app/utils/query/find" ) @@ -23,43 +21,44 @@ func (repo *ListProductsRepo) GetListing(id_lang uint, p find.Paging, filt *filt var listing []model.ProductInList var total int64 - subQuery := db.DB. - Table("ps_image"). - Select("id_product, MIN(id_image) AS id_image"). - Group("id_product") - - err := db.DB. - Table("ps_product"). + query := db.Get(). + Table("ps_product_shop AS ps"). Select(` - ps_product.id_product AS product_id, - ps_product_lang.name AS name, - ps_product.active AS active, - ps_product_lang.link_rewrite AS link_rewrite, - COALESCE(CONCAT( ?, '/', ps_image_shop.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp'), CONCAT( ?, '/', any_image.id_image, '-small_default/', ps_product_lang.link_rewrite, '.webp')) AS id_image - `, config.Get().Image.ImagePrefix, config.Get().Image.ImagePrefix). - Joins(` - LEFT JOIN ps_product_lang - ON ps_product_lang.id_product = ps_product.id_product - AND ps_product_lang.id_shop = ? - AND ps_product_lang.id_lang = ? - `, constdata.SHOP_ID, id_lang). - Joins(` - LEFT JOIN ps_image_shop - ON ps_image_shop.id_product = ps_product.id_product - AND ps_image_shop.id_shop = ? - AND ps_image_shop.cover = 1 - `, constdata.SHOP_ID). - Joins("LEFT JOIN (?) AS any_image ON ps_product.id_product = any_image.id_product", subQuery). - Limit(p.Limit()). - Offset(p.Offset()). - Scan(&listing).Error + ps.id_product AS product_id, + pl.name AS name, + pl.link_rewrite AS link_rewrite, + CONCAT('https://www.naluconcept.com', '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link, + cl.name AS category_name, + p.reference AS reference, + COUNT(DISTINCT pas.id_product_attribute) AS variants_number, + sa.quantity AS quantity + `). + Joins("JOIN ps_product p ON p.id_product = ps.id_product"). + Joins("JOIN ps_category_product cp ON ps.id_product = cp.id_product"). + Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang). + Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1"). + Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang). + Joins("LEFT JOIN ps_product_attribute_shop pas ON pas.id_product = cp.id_product"). + Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product"). + Where("ps.active = ?", 1). + Group("cp.id_product") + + // Apply all filters + if filt != nil { + filt.ApplyAll(query) + } + + // run counter first as query is without limit and offset + err := query.Count(&total).Error if err != nil { return find.Found[model.ProductInList]{}, err } - err = db.DB. - Table("ps_product"). - Count(&total).Error + err = query. + Order("ps.id_product DESC"). + Limit(p.Limit()). + Offset(p.Offset()). + Scan(&listing).Error if err != nil { return find.Found[model.ProductInList]{}, err } diff --git a/app/repos/localeSelectorRepo/localeSelectorRepo.go b/app/repos/localeSelectorRepo/localeSelectorRepo.go index f08a57b..d52ef87 100644 --- a/app/repos/localeSelectorRepo/localeSelectorRepo.go +++ b/app/repos/localeSelectorRepo/localeSelectorRepo.go @@ -27,9 +27,9 @@ func (repo *LocaleSelectorRepo) GetLanguages() ([]model.Language, error) { func (repo *LocaleSelectorRepo) GetCountriesAndCurrencies() ([]model.Country, error) { var countries []model.Country - err := db.DB.Table("b2b_countries"). - Select("b2b_countries.id, b2b_countries.name, b2b_countries.flag, ps_currency.id as id_currency, ps_currency.name as currency_name, ps_currency.iso_code as currency_iso_code"). - Joins("JOIN ps_currency ON ps_currency.id = b2b_countries.currency"). + err := db.DB. + Select("b2b_countries.id, b2b_countries.name, b2b_countries.flag, ps_currency.id_currency as id_currency, ps_currency.name as currency_name, ps_currency.iso_code as currency_iso_code"). + Joins("JOIN ps_currency ON ps_currency.id_currency = b2b_countries.currency"). Scan(&countries).Error return countries, err diff --git a/app/utils/query/filters/filters.go b/app/utils/query/filters/filters.go index 3f7a26d..f818406 100644 --- a/app/utils/query/filters/filters.go +++ b/app/utils/query/filters/filters.go @@ -55,7 +55,9 @@ func WhereFromStrings(column, conditionOperator, value string) Filter { value = strings.ReplaceAll(value, "~", "") filt = func(d *gorm.DB) *gorm.DB { - return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%") + // return d.Where("lower("+column+`) LIKE lower(?)`, "%"+value+"%") + // (jeśli masz collation case-insensitive, np. utf8mb4_general_ci) + return d.Where(column+` LIKE ?`, "%"+value+"%") } @@ -65,6 +67,33 @@ func WhereFromStrings(column, conditionOperator, value string) Filter { } } + // Handle IN operator for comma-separated values (e.g., product_id_in=1,2,3,4) + if conditionOperator == "IN" { + parts := strings.Split(value, ",") + var values []interface{} + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + // Try to parse as int first + if i, err := strconv.ParseInt(p, 10, 64); err == nil { + values = append(values, i) + } else if f, err := strconv.ParseFloat(p, 64); err == nil { + values = append(values, f) + } else { + values = append(values, p) + } + } + filt = func(d *gorm.DB) *gorm.DB { + return d.Where(column+" IN ?", values) + } + return Filter{ + category: WHERE_FILTER, + filter: filt, + } + } + if strings.Contains(value, "]") && strings.Contains(value, "[") { period := strings.ReplaceAll(value, "[", "") period = strings.ReplaceAll(period, "]", "") diff --git a/app/utils/query/query_params/where_scope_from_query.go b/app/utils/query/query_params/where_scope_from_query.go index be4de92..fe8d992 100644 --- a/app/utils/query/query_params/where_scope_from_query.go +++ b/app/utils/query/query_params/where_scope_from_query.go @@ -46,7 +46,7 @@ func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMappin } func extractOperator(key string) (base string, operatorSuffix string) { - suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq"} + suffixes := []string{"_gt", "_gte", "_lt", "_lte", "_eq", "_neq", "_in"} for _, suf := range suffixes { if strings.HasSuffix(key, suf) { return strings.TrimSuffix(key, suf), suf[1:] @@ -69,6 +69,8 @@ func resolveOperator(suffix string) string { return "!=" case "eq": return "=" + case "in": + return "IN" default: return "LIKE" } diff --git a/bo/src/components/customer/PageCustomerData.vue b/bo/src/components/customer/PageCustomerData.vue index dbe2448..3fb35cc 100644 --- a/bo/src/components/customer/PageCustomerData.vue +++ b/bo/src/components/customer/PageCustomerData.vue @@ -1,89 +1,102 @@ diff --git a/bo/src/components/customer/PageProductsList.vue b/bo/src/components/customer/PageProductsList.vue index d8bacfa..62630b4 100644 --- a/bo/src/components/customer/PageProductsList.vue +++ b/bo/src/components/customer/PageProductsList.vue @@ -35,7 +35,7 @@ - product image {{ @@ -49,6 +49,9 @@ +
+ +
No products found
@@ -59,17 +62,21 @@ \ No newline at end of file diff --git a/bo/src/views/RepoChartView.vue b/bo/src/views/RepoChartView.vue index 122d31b..ed81c31 100644 --- a/bo/src/views/RepoChartView.vue +++ b/bo/src/views/RepoChartView.vue @@ -238,7 +238,7 @@ const columns = computed[]>(() => [
- +