package postgres import ( "context" "errors" "fmt" "strings" "time" "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx" "github.com/saveinmed/backend-go/internal/domain" ) // Repository implements the data access layer using sqlx + pgx. type Repository struct { db *sqlx.DB } // New creates a Postgres-backed repository and configures pooling. func New(db *sqlx.DB) *Repository { return &Repository{db: db} } func (r *Repository) CreateCompany(ctx context.Context, company *domain.Company) error { now := time.Now().UTC() company.CreatedAt = now company.UpdatedAt = now query := `INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at) VALUES (:id, :cnpj, :corporate_name, :category, :license_number, :is_verified, :latitude, :longitude, :city, :state, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, company) return err } func (r *Repository) ListCompanies(ctx context.Context, filter domain.CompanyFilter) ([]domain.Company, int64, error) { baseQuery := `FROM companies` var args []any var clauses []string if filter.Category != "" { clauses = append(clauses, fmt.Sprintf("category = $%d", len(args)+1)) args = append(args, filter.Category) } if filter.Search != "" { clauses = append(clauses, fmt.Sprintf("(corporate_name ILIKE $%d OR cnpj ILIKE $%d)", len(args)+1, len(args)+1)) args = append(args, "%"+filter.Search+"%") } if filter.City != "" { clauses = append(clauses, fmt.Sprintf("city = $%d", len(args)+1)) args = append(args, filter.City) } if filter.State != "" { clauses = append(clauses, fmt.Sprintf("state = $%d", len(args)+1)) args = append(args, filter.State) } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } var total int64 if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } if filter.Limit <= 0 { filter.Limit = 20 } args = append(args, filter.Limit, filter.Offset) listQuery := fmt.Sprintf("SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) var companies []domain.Company if err := r.db.SelectContext(ctx, &companies, listQuery, args...); err != nil { return nil, 0, err } return companies, total, nil } func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) { var company domain.Company query := `SELECT id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, created_at, updated_at FROM companies WHERE id = $1` if err := r.db.GetContext(ctx, &company, query, id); err != nil { return nil, err } return &company, nil } func (r *Repository) UpdateCompany(ctx context.Context, company *domain.Company) error { company.UpdatedAt = time.Now().UTC() query := `UPDATE companies SET cnpj = :cnpj, corporate_name = :corporate_name, category = :category, license_number = :license_number, is_verified = :is_verified, latitude = :latitude, longitude = :longitude, city = :city, state = :state, updated_at = :updated_at WHERE id = :id` res, err := r.db.NamedExecContext(ctx, query, company) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("company not found") } return nil } func (r *Repository) DeleteCompany(ctx context.Context, id uuid.UUID) error { var count int if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM users WHERE company_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("company has related users") } if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM products WHERE seller_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("company has related products") } if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM orders WHERE buyer_id = $1 OR seller_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("company has related orders") } if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM reviews WHERE buyer_id = $1 OR seller_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("company has related reviews") } if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM cart_items WHERE buyer_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("company has related cart items") } res, err := r.db.ExecContext(ctx, `DELETE FROM companies WHERE id = $1`, id) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("company not found") } return nil } func (r *Repository) CreateProduct(ctx context.Context, product *domain.Product) error { now := time.Now().UTC() product.CreatedAt = now product.UpdatedAt = now query := `INSERT INTO products (id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at) VALUES (:id, :seller_id, :name, :description, :batch, :expires_at, :price_cents, :stock, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, product) return err } func (r *Repository) ListProducts(ctx context.Context, filter domain.ProductFilter) ([]domain.Product, int64, error) { baseQuery := `FROM products` var args []any var clauses []string if filter.SellerID != nil { clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1)) args = append(args, *filter.SellerID) } if filter.Search != "" { clauses = append(clauses, fmt.Sprintf("name ILIKE $%d", len(args)+1)) args = append(args, "%"+filter.Search+"%") } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } var total int64 if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } if filter.Limit <= 0 { filter.Limit = 20 } args = append(args, filter.Limit, filter.Offset) listQuery := fmt.Sprintf("SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) var products []domain.Product if err := r.db.SelectContext(ctx, &products, listQuery, args...); err != nil { return nil, 0, err } return products, total, nil } // SearchProducts returns products with distance from buyer, ordered by expiration date. // Tenant info is anonymized (only city/state shown, not company name/ID). func (r *Repository) SearchProducts(ctx context.Context, filter domain.ProductSearchFilter) ([]domain.ProductWithDistance, int64, error) { baseQuery := `FROM products p INNER JOIN companies c ON p.seller_id = c.id` var args []any var clauses []string if filter.Search != "" { clauses = append(clauses, fmt.Sprintf("p.name ILIKE $%d", len(args)+1)) args = append(args, "%"+filter.Search+"%") } if filter.MinPriceCents != nil { clauses = append(clauses, fmt.Sprintf("p.price_cents >= $%d", len(args)+1)) args = append(args, *filter.MinPriceCents) } if filter.MaxPriceCents != nil { clauses = append(clauses, fmt.Sprintf("p.price_cents <= $%d", len(args)+1)) args = append(args, *filter.MaxPriceCents) } if filter.ExpiresAfter != nil { clauses = append(clauses, fmt.Sprintf("p.expires_at >= $%d", len(args)+1)) args = append(args, *filter.ExpiresAfter) } if filter.ExpiresBefore != nil { clauses = append(clauses, fmt.Sprintf("p.expires_at <= $%d", len(args)+1)) args = append(args, *filter.ExpiresBefore) } // Always filter only available products clauses = append(clauses, "p.stock > 0") where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } var total int64 countQuery := "SELECT count(*) " + baseQuery + where if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { return nil, 0, err } if filter.Limit <= 0 { filter.Limit = 20 } args = append(args, filter.Limit, filter.Offset) // Select products with tenant location info (anonymous: no company name/ID) listQuery := fmt.Sprintf(` SELECT p.id, p.seller_id, p.name, p.description, p.batch, p.expires_at, p.price_cents, p.stock, p.created_at, p.updated_at, c.city, c.state, c.latitude, c.longitude %s%s ORDER BY p.expires_at ASC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args)) type productRow struct { domain.Product City string `db:"city"` State string `db:"state"` Latitude float64 `db:"latitude"` Longitude float64 `db:"longitude"` } var rows []productRow if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil { return nil, 0, err } // Calculate distance and build response results := make([]domain.ProductWithDistance, 0, len(rows)) for _, row := range rows { dist := domain.HaversineDistance(filter.BuyerLat, filter.BuyerLng, row.Latitude, row.Longitude) // Filter by max distance if specified if filter.MaxDistanceKm != nil && dist > *filter.MaxDistanceKm { continue } results = append(results, domain.ProductWithDistance{ Product: row.Product, DistanceKm: dist, TenantCity: row.City, TenantState: row.State, }) } return results, total, nil } func (r *Repository) GetProduct(ctx context.Context, id uuid.UUID) (*domain.Product, error) { var product domain.Product query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE id = $1` if err := r.db.GetContext(ctx, &product, query, id); err != nil { return nil, err } return &product, nil } func (r *Repository) UpdateProduct(ctx context.Context, product *domain.Product) error { product.UpdatedAt = time.Now().UTC() query := `UPDATE products SET seller_id = :seller_id, name = :name, description = :description, batch = :batch, expires_at = :expires_at, price_cents = :price_cents, stock = :stock, updated_at = :updated_at WHERE id = :id` res, err := r.db.NamedExecContext(ctx, query, product) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("product not found") } return nil } func (r *Repository) DeleteProduct(ctx context.Context, id uuid.UUID) error { var count int if err := r.db.GetContext(ctx, &count, `SELECT COUNT(*) FROM order_items WHERE product_id = $1`, id); err != nil { return err } if count > 0 { return errors.New("product has related orders") } tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM inventory_adjustments WHERE product_id = $1`, id); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `DELETE FROM cart_items WHERE product_id = $1`, id); err != nil { _ = tx.Rollback() return err } res, err := tx.ExecContext(ctx, `DELETE FROM products WHERE id = $1`, id) if err != nil { _ = tx.Rollback() return err } rows, err := res.RowsAffected() if err != nil { _ = tx.Rollback() return err } if rows == 0 { _ = tx.Rollback() return errors.New("product not found") } return tx.Commit() } func (r *Repository) CreateOrder(ctx context.Context, order *domain.Order) error { now := time.Now().UTC() order.CreatedAt = now order.UpdatedAt = now tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } orderQuery := `INSERT INTO orders (id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)` if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, order.Shipping.RecipientName, order.Shipping.Street, order.Shipping.Number, order.Shipping.Complement, order.Shipping.District, order.Shipping.City, order.Shipping.State, order.Shipping.ZipCode, order.Shipping.Country, order.CreatedAt, order.UpdatedAt); err != nil { _ = tx.Rollback() return err } itemQuery := `INSERT INTO order_items (id, order_id, product_id, quantity, unit_cents, batch, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7)` for i := range order.Items { item := &order.Items[i] item.ID = uuid.Must(uuid.NewV7()) item.OrderID = order.ID if _, err := tx.ExecContext(ctx, itemQuery, item.ID, item.OrderID, item.ProductID, item.Quantity, item.UnitCents, item.Batch, item.ExpiresAt); err != nil { _ = tx.Rollback() return err } } return tx.Commit() } func (r *Repository) ListOrders(ctx context.Context, filter domain.OrderFilter) ([]domain.Order, int64, error) { baseQuery := `FROM orders` var args []any var clauses []string if filter.BuyerID != nil { clauses = append(clauses, fmt.Sprintf("buyer_id = $%d", len(args)+1)) args = append(args, *filter.BuyerID) } if filter.SellerID != nil { clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1)) args = append(args, *filter.SellerID) } if filter.Status != "" { clauses = append(clauses, fmt.Sprintf("status = $%d", len(args)+1)) args = append(args, filter.Status) } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } var total int64 if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } if filter.Limit <= 0 { filter.Limit = 20 } args = append(args, filter.Limit, filter.Offset) listQuery := fmt.Sprintf(`SELECT id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args)) var rows []struct { ID uuid.UUID `db:"id"` BuyerID uuid.UUID `db:"buyer_id"` SellerID uuid.UUID `db:"seller_id"` Status domain.OrderStatus `db:"status"` TotalCents int64 `db:"total_cents"` ShippingRecipientName string `db:"shipping_recipient_name"` ShippingStreet string `db:"shipping_street"` ShippingNumber string `db:"shipping_number"` ShippingComplement string `db:"shipping_complement"` ShippingDistrict string `db:"shipping_district"` ShippingCity string `db:"shipping_city"` ShippingState string `db:"shipping_state"` ShippingZipCode string `db:"shipping_zip_code"` ShippingCountry string `db:"shipping_country"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } if err := r.db.SelectContext(ctx, &rows, listQuery, args...); err != nil { return nil, 0, err } orders := make([]domain.Order, 0, len(rows)) itemQuery := `SELECT id, order_id, product_id, quantity, unit_cents, batch, expires_at FROM order_items WHERE order_id = $1` for _, row := range rows { var items []domain.OrderItem if err := r.db.SelectContext(ctx, &items, itemQuery, row.ID); err != nil { return nil, 0, err } orders = append(orders, domain.Order{ ID: row.ID, BuyerID: row.BuyerID, SellerID: row.SellerID, Status: row.Status, TotalCents: row.TotalCents, Items: items, Shipping: domain.ShippingAddress{ RecipientName: row.ShippingRecipientName, Street: row.ShippingStreet, Number: row.ShippingNumber, Complement: row.ShippingComplement, District: row.ShippingDistrict, City: row.ShippingCity, State: row.ShippingState, ZipCode: row.ShippingZipCode, Country: row.ShippingCountry, }, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }) } return orders, total, nil } func (r *Repository) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) { var row struct { ID uuid.UUID `db:"id"` BuyerID uuid.UUID `db:"buyer_id"` SellerID uuid.UUID `db:"seller_id"` Status domain.OrderStatus `db:"status"` TotalCents int64 `db:"total_cents"` ShippingRecipientName string `db:"shipping_recipient_name"` ShippingStreet string `db:"shipping_street"` ShippingNumber string `db:"shipping_number"` ShippingComplement string `db:"shipping_complement"` ShippingDistrict string `db:"shipping_district"` ShippingCity string `db:"shipping_city"` ShippingState string `db:"shipping_state"` ShippingZipCode string `db:"shipping_zip_code"` ShippingCountry string `db:"shipping_country"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, shipping_recipient_name, shipping_street, shipping_number, shipping_complement, shipping_district, shipping_city, shipping_state, shipping_zip_code, shipping_country, created_at, updated_at FROM orders WHERE id = $1` if err := r.db.GetContext(ctx, &row, orderQuery, id); err != nil { return nil, err } var items []domain.OrderItem itemQuery := `SELECT id, order_id, product_id, quantity, unit_cents, batch, expires_at FROM order_items WHERE order_id = $1` if err := r.db.SelectContext(ctx, &items, itemQuery, id); err != nil { return nil, err } order := &domain.Order{ ID: row.ID, BuyerID: row.BuyerID, SellerID: row.SellerID, Status: row.Status, TotalCents: row.TotalCents, Items: items, Shipping: domain.ShippingAddress{ RecipientName: row.ShippingRecipientName, Street: row.ShippingStreet, Number: row.ShippingNumber, Complement: row.ShippingComplement, District: row.ShippingDistrict, City: row.ShippingCity, State: row.ShippingState, ZipCode: row.ShippingZipCode, Country: row.ShippingCountry, }, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } return order, nil } func (r *Repository) UpdateOrderStatus(ctx context.Context, id uuid.UUID, status domain.OrderStatus) error { query := `UPDATE orders SET status = $1, updated_at = $2 WHERE id = $3` res, err := r.db.ExecContext(ctx, query, status, time.Now().UTC(), id) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("order not found") } return nil } func (r *Repository) DeleteOrder(ctx context.Context, id uuid.UUID) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return err } if _, err := tx.ExecContext(ctx, `DELETE FROM reviews WHERE order_id = $1`, id); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `DELETE FROM shipments WHERE order_id = $1`, id); err != nil { _ = tx.Rollback() return err } if _, err := tx.ExecContext(ctx, `DELETE FROM order_items WHERE order_id = $1`, id); err != nil { _ = tx.Rollback() return err } res, err := tx.ExecContext(ctx, `DELETE FROM orders WHERE id = $1`, id) if err != nil { _ = tx.Rollback() return err } rows, err := res.RowsAffected() if err != nil { _ = tx.Rollback() return err } if rows == 0 { _ = tx.Rollback() return errors.New("order not found") } return tx.Commit() } func (r *Repository) CreateShipment(ctx context.Context, shipment *domain.Shipment) error { now := time.Now().UTC() shipment.CreatedAt = now shipment.UpdatedAt = now query := `INSERT INTO shipments (id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at) VALUES (:id, :order_id, :carrier, :tracking_code, :external_tracking, :status, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, shipment) return err } func (r *Repository) GetShipmentByOrderID(ctx context.Context, orderID uuid.UUID) (*domain.Shipment, error) { var shipment domain.Shipment query := `SELECT id, order_id, carrier, tracking_code, external_tracking, status, created_at, updated_at FROM shipments WHERE order_id = $1` if err := r.db.GetContext(ctx, &shipment, query, orderID); err != nil { return nil, err } return &shipment, nil } func (r *Repository) AdjustInventory(ctx context.Context, productID uuid.UUID, delta int64, reason string) (*domain.InventoryItem, error) { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return nil, err } var product domain.Product if err := tx.GetContext(ctx, &product, `SELECT id, seller_id, name, batch, expires_at, price_cents, stock, updated_at FROM products WHERE id = $1 FOR UPDATE`, productID); err != nil { _ = tx.Rollback() return nil, err } newStock := product.Stock + delta if newStock < 0 { _ = tx.Rollback() return nil, errors.New("inventory cannot be negative") } now := time.Now().UTC() if _, err := tx.ExecContext(ctx, `UPDATE products SET stock = $1, updated_at = $2 WHERE id = $3`, newStock, now, productID); err != nil { _ = tx.Rollback() return nil, err } adj := domain.InventoryAdjustment{ ID: uuid.Must(uuid.NewV7()), ProductID: productID, Delta: delta, Reason: reason, CreatedAt: now, } if _, err := tx.NamedExecContext(ctx, `INSERT INTO inventory_adjustments (id, product_id, delta, reason, created_at) VALUES (:id, :product_id, :delta, :reason, :created_at)`, &adj); err != nil { _ = tx.Rollback() return nil, err } if err := tx.Commit(); err != nil { return nil, err } return &domain.InventoryItem{ ProductID: productID, SellerID: product.SellerID, Name: product.Name, Batch: product.Batch, ExpiresAt: product.ExpiresAt, Quantity: newStock, PriceCents: product.PriceCents, UpdatedAt: now, }, nil } func (r *Repository) ListInventory(ctx context.Context, filter domain.InventoryFilter) ([]domain.InventoryItem, int64, error) { baseQuery := `FROM products` args := []any{} clauses := []string{} if filter.ExpiringBefore != nil { clauses = append(clauses, fmt.Sprintf("expires_at <= $%d", len(args)+1)) args = append(args, *filter.ExpiringBefore) } if filter.SellerID != nil { clauses = append(clauses, fmt.Sprintf("seller_id = $%d", len(args)+1)) args = append(args, *filter.SellerID) } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } var total int64 if err := r.db.GetContext(ctx, &total, "SELECT count(*) "+baseQuery+where, args...); err != nil { return nil, 0, err } if filter.Limit <= 0 { filter.Limit = 20 } args = append(args, filter.Limit, filter.Offset) listQuery := fmt.Sprintf(`SELECT id AS product_id, seller_id, name, batch, expires_at, stock AS quantity, price_cents, updated_at %s%s ORDER BY expires_at ASC LIMIT $%d OFFSET $%d`, baseQuery, where, len(args)-1, len(args)) var items []domain.InventoryItem if err := r.db.SelectContext(ctx, &items, listQuery, args...); err != nil { return nil, 0, err } return items, total, nil } func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error { now := time.Now().UTC() user.CreatedAt = now user.UpdatedAt = now query := `INSERT INTO users (id, company_id, role, name, email, password_hash, created_at, updated_at) VALUES (:id, :company_id, :role, :name, :email, :password_hash, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, user) return err } func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error) { baseQuery := `FROM users` var args []any var clauses []string if filter.CompanyID != nil { clauses = append(clauses, fmt.Sprintf("company_id = $%d", len(args)+1)) args = append(args, *filter.CompanyID) } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } countQuery := "SELECT count(*) " + baseQuery + where var total int64 if err := r.db.GetContext(ctx, &total, countQuery, args...); err != nil { return nil, 0, err } args = append(args, filter.Limit, filter.Offset) listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) var users []domain.User if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil { return nil, 0, err } return users, total, nil } func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) { var user domain.User query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE id = $1` if err := r.db.GetContext(ctx, &user, query, id); err != nil { return nil, err } return &user, nil } func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { var user domain.User query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE email = $1` if err := r.db.GetContext(ctx, &user, query, email); err != nil { return nil, err } return &user, nil } func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error { user.UpdatedAt = time.Now().UTC() query := `UPDATE users SET company_id = :company_id, role = :role, name = :name, email = :email, password_hash = :password_hash, updated_at = :updated_at WHERE id = :id` res, err := r.db.NamedExecContext(ctx, query, user) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("user not found") } return nil } func (r *Repository) DeleteUser(ctx context.Context, id uuid.UUID) error { res, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("user not found") } return nil } func (r *Repository) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) { now := time.Now().UTC() item.CreatedAt = now item.UpdatedAt = now query := `INSERT INTO cart_items (id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at) VALUES (:id, :buyer_id, :product_id, :quantity, :unit_cents, :batch, :expires_at, :created_at, :updated_at) ON CONFLICT (buyer_id, product_id) DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity, unit_cents = EXCLUDED.unit_cents, batch = EXCLUDED.batch, expires_at = EXCLUDED.expires_at, updated_at = EXCLUDED.updated_at RETURNING id, buyer_id, product_id, quantity, unit_cents, batch, expires_at, created_at, updated_at` var saved domain.CartItem rows, err := r.db.NamedQueryContext(ctx, query, item) if err != nil { return nil, err } defer rows.Close() if rows.Next() { if err := rows.StructScan(&saved); err != nil { return nil, err } return &saved, nil } return nil, errors.New("failed to persist cart item") } func (r *Repository) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) { query := `SELECT ci.id, ci.buyer_id, ci.product_id, ci.quantity, ci.unit_cents, ci.batch, ci.expires_at, ci.created_at, ci.updated_at, p.name AS product_name FROM cart_items ci JOIN products p ON p.id = ci.product_id WHERE ci.buyer_id = $1 ORDER BY ci.created_at DESC` var items []domain.CartItem if err := r.db.SelectContext(ctx, &items, query, buyerID); err != nil { return nil, err } return items, nil } func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error { res, err := r.db.ExecContext(ctx, "DELETE FROM cart_items WHERE id = $1 AND buyer_id = $2", id, buyerID) if err != nil { return err } rows, err := res.RowsAffected() if err != nil { return err } if rows == 0 { return errors.New("cart item not found") } return nil } func (r *Repository) CreateReview(ctx context.Context, review *domain.Review) error { now := time.Now().UTC() review.CreatedAt = now query := `INSERT INTO reviews (id, order_id, buyer_id, seller_id, rating, comment, created_at) VALUES (:id, :order_id, :buyer_id, :seller_id, :rating, :comment, :created_at)` _, err := r.db.NamedExecContext(ctx, query, review) return err } func (r *Repository) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) { var row struct { Avg *float64 `db:"avg"` Count int64 `db:"count"` } query := `SELECT AVG(rating)::float AS avg, COUNT(*) AS count FROM reviews WHERE seller_id = $1` if err := r.db.GetContext(ctx, &row, query, companyID); err != nil { return nil, err } avg := 0.0 if row.Avg != nil { avg = *row.Avg } return &domain.CompanyRating{CompanyID: companyID, AverageScore: avg, TotalReviews: row.Count}, nil } func (r *Repository) SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) { var sales struct { Total *int64 `db:"total"` Orders int64 `db:"orders"` } baseStatuses := []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)} if err := r.db.GetContext(ctx, &sales, `SELECT COALESCE(SUM(total_cents), 0) AS total, COUNT(*) AS orders FROM orders WHERE seller_id = $1 AND status = ANY($2)`, sellerID, baseStatuses); err != nil { return nil, err } var topProducts []domain.TopProduct topQuery := `SELECT oi.product_id, p.name, SUM(oi.quantity) AS total_quantity, SUM(oi.quantity * oi.unit_cents) AS revenue_cents FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN products p ON p.id = oi.product_id WHERE o.seller_id = $1 AND o.status = ANY($2) GROUP BY oi.product_id, p.name ORDER BY total_quantity DESC LIMIT 5` if err := r.db.SelectContext(ctx, &topProducts, topQuery, sellerID, baseStatuses); err != nil { return nil, err } var lowStock []domain.Product if err := r.db.SelectContext(ctx, &lowStock, `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE seller_id = $1 AND stock <= 10 ORDER BY stock ASC, updated_at DESC`, sellerID); err != nil { return nil, err } total := int64(0) if sales.Total != nil { total = *sales.Total } return &domain.SellerDashboard{ SellerID: sellerID, TotalSalesCents: total, OrdersCount: sales.Orders, TopProducts: topProducts, LowStockAlerts: lowStock, }, nil } func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error) { var gmv *int64 if err := r.db.GetContext(ctx, &gmv, `SELECT COALESCE(SUM(total_cents), 0) FROM orders WHERE status = ANY($1)`, []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)}); err != nil { return nil, err } var newCompanies int64 if err := r.db.GetContext(ctx, &newCompanies, `SELECT COUNT(*) FROM companies WHERE created_at >= $1`, since); err != nil { return nil, err } totalGMV := int64(0) if gmv != nil { totalGMV = *gmv } return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil } // InitSchema applies a minimal schema for development environments. func (r *Repository) InitSchema(ctx context.Context) error { schema := ` CREATE TABLE IF NOT EXISTS companies ( id UUID PRIMARY KEY, cnpj TEXT NOT NULL UNIQUE, corporate_name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'farmacia', license_number TEXT NOT NULL, is_verified BOOLEAN NOT NULL DEFAULT FALSE, latitude DOUBLE PRECISION NOT NULL DEFAULT 0, longitude DOUBLE PRECISION NOT NULL DEFAULT 0, city TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, company_id UUID NOT NULL REFERENCES companies(id), role TEXT NOT NULL, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS products ( id UUID PRIMARY KEY, seller_id UUID NOT NULL REFERENCES companies(id), name TEXT NOT NULL, description TEXT, batch TEXT NOT NULL, expires_at DATE NOT NULL, price_cents BIGINT NOT NULL, stock BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS inventory_adjustments ( id UUID PRIMARY KEY, product_id UUID NOT NULL REFERENCES products(id), delta BIGINT NOT NULL, reason TEXT, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY, buyer_id UUID NOT NULL REFERENCES companies(id), seller_id UUID NOT NULL REFERENCES companies(id), status TEXT NOT NULL, total_cents BIGINT NOT NULL, shipping_recipient_name TEXT, shipping_street TEXT, shipping_number TEXT, shipping_complement TEXT, shipping_district TEXT, shipping_city TEXT, shipping_state TEXT, shipping_zip_code TEXT, shipping_country TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS order_items ( id UUID PRIMARY KEY, order_id UUID NOT NULL REFERENCES orders(id), product_id UUID NOT NULL REFERENCES products(id), quantity BIGINT NOT NULL, unit_cents BIGINT NOT NULL, batch TEXT NOT NULL, expires_at DATE NOT NULL ); CREATE TABLE IF NOT EXISTS cart_items ( id UUID PRIMARY KEY, buyer_id UUID NOT NULL REFERENCES companies(id), product_id UUID NOT NULL REFERENCES products(id), quantity BIGINT NOT NULL, unit_cents BIGINT NOT NULL, batch TEXT, expires_at DATE, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, UNIQUE (buyer_id, product_id) ); CREATE TABLE IF NOT EXISTS reviews ( id UUID PRIMARY KEY, order_id UUID NOT NULL UNIQUE REFERENCES orders(id), buyer_id UUID NOT NULL REFERENCES companies(id), seller_id UUID NOT NULL REFERENCES companies(id), rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5), comment TEXT, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id); CREATE TABLE IF NOT EXISTS shipments ( id UUID PRIMARY KEY, order_id UUID NOT NULL UNIQUE REFERENCES orders(id), carrier TEXT NOT NULL, tracking_code TEXT, external_tracking TEXT, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); ` if _, err := r.db.ExecContext(ctx, schema); err != nil { return fmt.Errorf("apply schema: %w", err) } return nil }