Merge pull request 'filters' (#26) from filters into main

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-03-25 01:53:13 +00:00
17 changed files with 700 additions and 453 deletions

1
.env
View File

@@ -57,3 +57,4 @@ FILE_MAAL_PL_USER=git_operator
FILE_MAAL_PL_PASSWORD=1FnwqcEgIUjQHjt1 FILE_MAAL_PL_PASSWORD=1FnwqcEgIUjQHjt1
IMAGE_PREFIX=https://www.naluconcept.com # remove prefix to serv them from same host as presta IMAGE_PREFIX=https://www.naluconcept.com # remove prefix to serv them from same host as presta
CORS_ORGIN=https://www.naluconcept.com

59
.env_example Normal file
View File

@@ -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

View File

@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "b2b API", "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", "version": "1.0.0",
"contact": { "contact": {
"name": "API Support", "name": "API Support",
@@ -11,8 +11,8 @@
}, },
"servers": [ "servers": [
{ {
"url": "http://localhost:3000", "url": "/",
"description": "Development server" "description": "Development server on same host"
} }
], ],
"tags": [ "tags": [
@@ -48,17 +48,13 @@
"name": "Locale", "name": "Locale",
"description": "Locale selection endpoints (under /api/v1/restricted/langs-and-countries, requires authentication)" "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", "name": "Settings",
"description": "Application settings and configuration endpoints" "description": "Application settings and configuration endpoints"
},
{
"name": "Carts",
"description": "Shopping cart management endpoints (under /api/v1/restricted/carts, requires authentication)"
} }
], ],
"paths": { "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": { "/api/v1/public/auth/google": {
"get": { "get": {
"tags": ["Auth"], "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": { "/api/v1/settings": {
"get": { "get": {
"tags": ["Settings"], "tags": ["Settings"],
@@ -1043,7 +788,7 @@
"get": { "get": {
"tags": ["Products"], "tags": ["Products"],
"summary": "Get product listing", "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", "operationId": "getProductListing",
"security": [ "security": [
{ {
@@ -1064,12 +809,76 @@
{ {
"name": "elems", "name": "elems",
"in": "query", "in": "query",
"description": "Number of items per page", "description": "Number of items per page (max: 100, default: 30)",
"required": false, "required": false,
"schema": { "schema": {
"type": "integer", "type": "integer",
"default": 30 "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": { "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": { "components": {

View File

@@ -23,6 +23,7 @@ type Config struct {
Pdf PdfPrinter Pdf PdfPrinter
GoogleTranslate GoogleTranslateConfig GoogleTranslate GoogleTranslateConfig
Image ImageConfig Image ImageConfig
Cors CorsConfig
} }
type I18n struct { type I18n struct {
@@ -33,6 +34,10 @@ type ServerConfig struct {
Host string `env:"SERVER_HOST,0.0.0.0"` Host string `env:"SERVER_HOST,0.0.0.0"`
} }
type CorsConfig struct {
Origins []string `env:"CORS_ORGIN"`
}
type ImageConfig struct { type ImageConfig struct {
ImagePrefix string `env:"IMAGE_PREFIX"` ImagePrefix string `env:"IMAGE_PREFIX"`
} }
@@ -176,6 +181,11 @@ func load() *Config {
if err != nil { if err != nil {
slog.Error("not possible to load env variables for google translate : ", err.Error(), "") 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 return cfg
} }

View File

@@ -1,11 +1,23 @@
package middleware 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 // CORSMiddleware creates CORS middleware
func CORSMiddleware() fiber.Handler { func CORSMiddleware() fiber.Handler {
return func(c fiber.Ctx) error { 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-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

View File

@@ -2,11 +2,10 @@ package restricted
import ( import (
"git.ma-al.com/goc_daniel/b2b/app/config" "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/service/listProductsService"
"git.ma-al.com/goc_daniel/b2b/app/utils/i18n" "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/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/query/query_params"
"git.ma-al.com/goc_daniel/b2b/app/utils/response" "git.ma-al.com/goc_daniel/b2b/app/utils/response"
"git.ma-al.com/goc_daniel/b2b/app/utils/responseErrors" "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 { 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 { if err != nil {
return c.Status(responseErrors.GetErrorStatus(err)). return c.Status(responseErrors.GetErrorStatus(err)).
JSON(response.Make(nullable.GetNil(""), 0, responseErrors.GetErrorCode(c, 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))) return c.JSON(response.Make(&listing.Items, int(listing.Count), i18n.T_(c, response.Message_OK)))
} }
var columnMapping map[string]string = map[string]string{} var columnMappingGetListing map[string]string = map[string]string{
"product_id": "ps.id_product",
func ParseProductFilters(c fiber.Ctx) (find.Paging, *filters.FiltersList, error) { "name": "pl.name",
var p find.Paging "reference": "ps.reference",
fl := filters.NewFiltersList() "category_name": "cl.name",
"id_category": "cp.id_category",
pageNum, pageElems := query_params.ParsePagination(c) "quantity": "sa.quantity",
p = find.Paging{Page: pageNum, Elements: pageElems}
return p, &fl, nil
} }

View File

@@ -47,6 +47,11 @@ var swaggerHTML = `
url: "/openapi.json", url: "/openapi.json",
dom_id: '#swagger-ui', dom_id: '#swagger-ui',
deepLinking: true, deepLinking: true,
withCredentials: true,
"servers": [
{ "url": "http://localhost:3000" },
{ "url": "http://localhost:5173" }
],
presets: [ presets: [
SwaggerUIBundle.presets.apis, SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset SwaggerUIStandalonePreset

View File

@@ -9,3 +9,16 @@ type Country struct {
CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"` CurrencyISOCode string `gorm:"column:iso_code" json:"currency_iso_code"`
CurrencyName string `gorm:"column:name" json:"currency_name"` 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"
}

View File

@@ -62,11 +62,14 @@ type Product struct {
DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"` DeliveryDays uint `gorm:"column:delivery_days" json:"delivery_days" form:"delivery_days"`
} }
type ProductInList struct { type ProductInList struct {
ProductID uint `gorm:"column:product_id;primaryKey" json:"product_id" form:"product_id"` ProductID uint `gorm:"column:product_id" json:"product_id" form:"product_id"`
Name string `gorm:"column:name" json:"name" form:"name"` Name string `gorm:"column:name" json:"name" form:"name"`
ImageID string `gorm:"column:id_image"` LinkRewrite string `gorm:"column:link_rewrite" json:"link_rewrite"`
LinkRewrite string `gorm:"column:link_rewrite"` ImageLink string `gorm:"column:image_link" json:"image_link"`
Active uint `gorm:"column:active" json:"active" form:"active"` 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 { type ProductFilters struct {

View File

@@ -30,12 +30,22 @@ func (repo *CategoriesRepo) GetAllCategories(id_lang uint) ([]model.ScannedCateg
ps_category.is_root_category AS is_root_category, ps_category.is_root_category AS is_root_category,
ps_category_lang.link_rewrite AS link_rewrite, ps_category_lang.link_rewrite AS link_rewrite,
ps_lang.iso_code AS iso_code 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 = ? Joins(`
LEFT JOIN ps_category_shop ON ps_category_shop.id_category = ps_category.id_category AND ps_category_shop.id_shop = ? LEFT JOIN ps_category_lang
JOIN ps_lang ON ps_lang.id_lang = ps_category_lang.id_lang ON ps_category_lang.id_category = ps_category.id_category
`, AND ps_category_lang.id_shop = ?
constdata.SHOP_ID, id_lang, constdata.SHOP_ID). 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 Scan(&allCategories).Error
return allCategories, err return allCategories, err

View File

@@ -1,10 +1,8 @@
package listProductsRepo package listProductsRepo
import ( 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/db"
"git.ma-al.com/goc_daniel/b2b/app/model" "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/filters"
"git.ma-al.com/goc_daniel/b2b/app/utils/query/find" "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 listing []model.ProductInList
var total int64 var total int64
subQuery := db.DB. query := db.Get().
Table("ps_image"). Table("ps_product_shop AS ps").
Select("id_product, MIN(id_image) AS id_image").
Group("id_product")
err := db.DB.
Table("ps_product").
Select(` Select(`
ps_product.id_product AS product_id, ps.id_product AS product_id,
ps_product_lang.name AS name, pl.name AS name,
ps_product.active AS active, pl.link_rewrite AS link_rewrite,
ps_product_lang.link_rewrite AS link_rewrite, CONCAT('https://www.naluconcept.com', '/', ims.id_image, '-small_default/', pl.link_rewrite, '.webp') AS image_link,
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 cl.name AS category_name,
`, config.Get().Image.ImagePrefix, config.Get().Image.ImagePrefix). p.reference AS reference,
Joins(` COUNT(DISTINCT pas.id_product_attribute) AS variants_number,
LEFT JOIN ps_product_lang sa.quantity AS quantity
ON ps_product_lang.id_product = ps_product.id_product `).
AND ps_product_lang.id_shop = ? Joins("JOIN ps_product p ON p.id_product = ps.id_product").
AND ps_product_lang.id_lang = ? Joins("JOIN ps_category_product cp ON ps.id_product = cp.id_product").
`, constdata.SHOP_ID, id_lang). Joins("JOIN ps_product_lang pl ON pl.id_product = ps.id_product AND pl.id_lang = ?", id_lang).
Joins(` Joins("JOIN ps_image_shop ims ON ims.id_product = ps.id_product AND ims.cover = 1").
LEFT JOIN ps_image_shop Joins("JOIN ps_category_lang cl ON cl.id_category = ps.id_category_default AND cl.id_lang = ?", id_lang).
ON ps_image_shop.id_product = ps_product.id_product Joins("LEFT JOIN ps_product_attribute_shop pas ON pas.id_product = cp.id_product").
AND ps_image_shop.id_shop = ? Joins("LEFT JOIN ps_stock_available sa ON sa.id_product = ps.id_product").
AND ps_image_shop.cover = 1 Where("ps.active = ?", 1).
`, constdata.SHOP_ID). Group("cp.id_product")
Joins("LEFT JOIN (?) AS any_image ON ps_product.id_product = any_image.id_product", subQuery).
Limit(p.Limit()). // Apply all filters
Offset(p.Offset()). if filt != nil {
Scan(&listing).Error filt.ApplyAll(query)
}
// run counter first as query is without limit and offset
err := query.Count(&total).Error
if err != nil { if err != nil {
return find.Found[model.ProductInList]{}, err return find.Found[model.ProductInList]{}, err
} }
err = db.DB. err = query.
Table("ps_product"). Order("ps.id_product DESC").
Count(&total).Error Limit(p.Limit()).
Offset(p.Offset()).
Scan(&listing).Error
if err != nil { if err != nil {
return find.Found[model.ProductInList]{}, err return find.Found[model.ProductInList]{}, err
} }

View File

@@ -27,9 +27,9 @@ func (repo *LocaleSelectorRepo) GetLanguages() ([]model.Language, error) {
func (repo *LocaleSelectorRepo) GetCountriesAndCurrencies() ([]model.Country, error) { func (repo *LocaleSelectorRepo) GetCountriesAndCurrencies() ([]model.Country, error) {
var countries []model.Country var countries []model.Country
err := db.DB.Table("b2b_countries"). err := db.DB.
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"). 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 = b2b_countries.currency"). Joins("JOIN ps_currency ON ps_currency.id_currency = b2b_countries.currency").
Scan(&countries).Error Scan(&countries).Error
return countries, err return countries, err

View File

@@ -55,7 +55,9 @@ func WhereFromStrings(column, conditionOperator, value string) Filter {
value = strings.ReplaceAll(value, "~", "") value = strings.ReplaceAll(value, "~", "")
filt = func(d *gorm.DB) *gorm.DB { 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, "[") { if strings.Contains(value, "]") && strings.Contains(value, "[") {
period := strings.ReplaceAll(value, "[", "") period := strings.ReplaceAll(value, "[", "")
period = strings.ReplaceAll(period, "]", "") period = strings.ReplaceAll(period, "]", "")

View File

@@ -46,7 +46,7 @@ func ParseWhereScopes[T any](c fiber.Ctx, ignoredKeys []string, formColumnMappin
} }
func extractOperator(key string) (base string, operatorSuffix string) { 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 { for _, suf := range suffixes {
if strings.HasSuffix(key, suf) { if strings.HasSuffix(key, suf) {
return strings.TrimSuffix(key, suf), suf[1:] return strings.TrimSuffix(key, suf), suf[1:]
@@ -69,6 +69,8 @@ func resolveOperator(suffix string) string {
return "!=" return "!="
case "eq": case "eq":
return "=" return "="
case "in":
return "IN"
default: default:
return "LIKE" return "LIKE"
} }

View File

@@ -1,89 +1,102 @@
<template> <template>
<component :is="Default || 'div'"> <component :is="Default || 'div'">
<div class="container mx-auto mt-20"> <div class="container mx-auto mt-20">
<div class="flex flex-col gap-5 mb-6"> <div class="flex flex-col gap-5 mb-6">
<h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Customer Data') }}</h1> <h1 class="text-2xl font-bold text-black dark:text-white">{{ t('Customer Data') }}</h1>
<div v-if="!customerStore.hasAccount" class="flex flex-col items-center justify-center py-12"> <div v-if="!customerStore.hasAccount" class="flex flex-col items-center justify-center py-12">
<div class="text-center flex flex-col items-center justify-center mb-6"> <div class="text-center flex flex-col items-center justify-center mb-6">
<UIcon name="mdi:domain" class="text-[60px] text-gray-400 dark:text-gray-500" /> <UIcon name="mdi:domain" class="text-[60px] text-gray-400 dark:text-gray-500" />
<p class="mt-4 text-lg text-gray-600 dark:text-gray-400">{{ t('No customer account found') }}</p> <p class="mt-4 text-lg text-gray-600 dark:text-gray-400">{{ t('No customer account found') }}
<p class="text-sm text-gray-500 dark:text-gray-500">{{ t('Create an account to manage your company data') }}</p> </p>
</div> <p class="text-sm text-gray-500 dark:text-gray-500">{{ t('Create an account to manage your company data') }}</p>
<UButton color="primary" @click="goToCreateAccount"
class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
<UIcon name="mdi:add-bold" />
{{ t('Create Account') }}
</UButton>
</div>
<div v-else class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-5">
<div
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-4">
<h2 class="text-xl font-semibold text-black dark:text-white mb-4 flex items-center gap-2">
<UIcon name="mdi:domain"
class="text-[24px] text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
{{ t('Company Information') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ t('Company Name') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.companyName || '-' }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('Company Email') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.companyEmail || '-' }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('REGON') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.regon || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('NIP') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.nip || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('VAT') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.vat || '-' }}</p>
</div>
</div>
</div> </div>
<div class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-4"> <UButton color="primary" @click="goToCreateAccount"
<h2 class="text-xl font-semibold text-black dark:text-white mb-4 flex items-center gap-2"> class="bg-(--accent-blue-light) dark:bg-(--accent-blue-dark) text-white hover:bg-(--accent-blue-dark) dark:hover:bg-(--accent-blue-light)">
<UIcon name="mdi:map-marker" <UIcon name="mdi:add-bold" />
class="text-[24px] text-(--accent-blue-light) dark:text-(--accent-blue-dark)" /> {{ t('Create Account') }}
{{ t('Addresses') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ t('Company Address') }}</label>
<div v-if="companyAddress"
class="p-4 bg-white dark:bg-(--black) rounded-md border border-(--border-light) dark:border-(--border-dark)">
<p class="text-black dark:text-white">{{ companyAddress.street }}</p>
<p class="text-black dark:text-white">{{ companyAddress.zipCode }}, {{
companyAddress.city }}</p>
<p class="text-black dark:text-white">{{ companyAddress.country }}</p>
</div>
<p v-else class="text-gray-400 dark:text-gray-500">-</p>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UButton color="primary" variant="outline"
class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) border-(--accent-blue-light) dark:border-(--accent-blue-dark)"
@click="goToCreateAccount">
<UIcon name="ic:sharp-edit" />
{{ t('Edit Account') }}
</UButton> </UButton>
</div> </div>
<div v-else class="flex flex-col gap-3">
<div class="grid grid-cols-2 gap-5">
<div
class="bg-(--second-light) dark:bg-(--main-dark) rounded-lg border border-(--border-light) dark:border-(--border-dark) p-4">
<h2 class="text-xl font-semibold text-black dark:text-white mb-4 flex items-center gap-2">
<UIcon name="mdi:domain"
class="text-[24px] text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
{{ t('Company Information') }}
</h2>
<div class="grid grid-cols-1 gap-10">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ t('Company Name') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.companyName || '-'}}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{
t('Company Email') }}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.companyEmail || '-'}}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{ t('REGON')}}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.regon || '-' }}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('NIP')}}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.nip || '-' }}
</p>
</div>
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">{{t('VAT')}}</label>
<p class="text-black dark:text-white">{{ customerStore.customer?.vat || '-' }}
</p>
</div>
</div>
<div>
<h2
class="text-xl font-semibold text-black dark:text-white mb-2 flex items-center gap-2">
<UIcon name="mdi:map-marker"
class="text-[24px] text-(--accent-blue-light) dark:text-(--accent-blue-dark)" />
{{ t('Addresses') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label
class="block text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ t('Company Address') }}</label>
<div v-if="companyAddress"
class="p-4 bg-white dark:bg-(--black) rounded-md border border-(--border-light) dark:border-(--border-dark)">
<p class="text-black dark:text-white">{{ companyAddress.street }}</p>
<p class="text-black dark:text-white">{{ companyAddress.zipCode }},
{{ companyAddress.city }}</p>
<p class="text-black dark:text-white">{{ companyAddress.country }}</p>
</div>
<p v-else class="text-gray-400 dark:text-gray-500">-</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end">
<UButton color="primary" variant="outline"
class="text-(--accent-blue-light) dark:text-(--accent-blue-dark) border-(--accent-blue-light) dark:border-(--accent-blue-dark)"
@click="goToCreateAccount">
<UIcon name="ic:sharp-edit" />
{{ t('Edit Account') }}
</UButton>
</div>
</div>
</div> </div>
</div> </div>
</div>
</component> </component>
</template> </template>

View File

@@ -35,7 +35,7 @@
<tr v-for="product in productsList" :key="product.product_id" <tr v-for="product in productsList" :key="product.product_id"
class="hover:bg-gray-50 dark:hover:bg-gray-800"> class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<img :src="getImageUrl(product.ImageID, product.LinkRewrite,)" alt="product image" <img :src="product.ImageID" alt="product image"
class="w-16 h-16 object-cover rounded" /> class="w-16 h-16 object-cover rounded" />
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-white">{{
@@ -49,6 +49,9 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="flex justify-center items-center py-8">
<UPagination v-model:page="page" :total="total" :page-size="perPage" />
</div>
<div v-if="productsList.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400"> <div v-if="productsList.length === 0" class="text-center py-8 text-gray-500 dark:text-gray-400">
No products found No products found
</div> </div>
@@ -59,17 +62,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, Suspense } from 'vue' import { ref, onMounted, watch } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson' import { useFetchJson } from '@/composable/useFetchJson'
import Default from '@/layouts/default.vue' import Default from '@/layouts/default.vue'
// import CategoryMenu from '@/components/inner/categoryMenu.vue'
interface Product { interface Product {
product_id: number product_id: number
name: string name: string
ImageID: number ImageID: string
LinkRewrite: string LinkRewrite: string
} }
const page = ref(1)
const perPage = ref(15)
const total = ref(0)
interface ApiResponse { interface ApiResponse {
message: string message: string
items: Product[] items: Product[]
@@ -80,21 +87,26 @@ const productsList = ref<Product[]>([])
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
function getImageUrl(imageID: number, linkRewrite: string, size: string = 'small_default') {
return `https://www.naluconcept.com/${imageID}-${size}/${linkRewrite}.webp`
}
async function fetchProductList() { async function fetchProductList() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const response = await useFetchJson('/api/v1/restricted/list-products/get-listing?p&elems&shopID=1') as ApiResponse const response = await useFetchJson(
`api/v1/restricted/list-products/get-listing?p=${page.value}&elems=${perPage.value}`
) as ApiResponse
productsList.value = response.items || [] productsList.value = response.items || []
total.value = response.count || 0
} catch (e: unknown) { } catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load products' error.value = e instanceof Error ? e.message : 'Failed to load products'
console.error(e)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
watch(page, () => {
fetchProductList()
})
onMounted(fetchProductList) onMounted(fetchProductList)
</script> </script>

View File

@@ -238,7 +238,7 @@ const columns = computed<TableColumn<IssueTimeSummary>[]>(() => [
</h2> </h2>
<UTable :data="issues" :columns="columns" class="flex-1 dark:text-white! text-dark" /> <UTable :data="issues" :columns="columns" class="flex-1 dark:text-white! text-dark" />
<div class="pt-4 flex justify-center items-center dark:text-white! text-dark"> <div class="pt-4 flex justify-center items-center dark:text-white! text-dark">
<UPagination v-model:page="page" :total="totalItems" /> <UPagination v-model:page="page" :total="totalItems" :page-size="10" />
</div> </div>
</div> </div>