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, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at) VALUES (:id, :role, :cnpj, :corporate_name, :license_number, :is_verified, :created_at, :updated_at)` _, err := r.db.NamedExecContext(ctx, query, company) return err } func (r *Repository) ListCompanies(ctx context.Context) ([]domain.Company, error) { var companies []domain.Company query := `SELECT id, role, cnpj, corporate_name, license_number, is_verified, created_at, updated_at FROM companies ORDER BY created_at DESC` if err := r.db.SelectContext(ctx, &companies, query); err != nil { return nil, err } return companies, nil } func (r *Repository) GetCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) { var company domain.Company query := `SELECT id, role, cnpj, corporate_name, license_number, is_verified, 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 role = :role, cnpj = :cnpj, corporate_name = :corporate_name, license_number = :license_number, is_verified = :is_verified, 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) 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) ([]domain.Product, error) { var products []domain.Product query := `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products ORDER BY created_at DESC` if err := r.db.SelectContext(ctx, &products, query); err != nil { return nil, err } return products, 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) 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, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)` if _, err := tx.ExecContext(ctx, orderQuery, order.ID, order.BuyerID, order.SellerID, order.Status, order.TotalCents, 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) GetOrder(ctx context.Context, id uuid.UUID) (*domain.Order, error) { var order domain.Order orderQuery := `SELECT id, buyer_id, seller_id, status, total_cents, created_at, updated_at FROM orders WHERE id = $1` if err := r.db.GetContext(ctx, &order, 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.Items = items 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) 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, error) { args := []any{} clauses := []string{} if filter.ExpiringBefore != nil { clauses = append(clauses, fmt.Sprintf("expires_at <= $%d", len(args)+1)) args = append(args, *filter.ExpiringBefore) } where := "" if len(clauses) > 0 { where = " WHERE " + strings.Join(clauses, " AND ") } query := fmt.Sprintf(`SELECT id AS product_id, seller_id, name, batch, expires_at, stock AS quantity, price_cents, updated_at FROM products%s ORDER BY expires_at ASC`, where) var items []domain.InventoryItem if err := r.db.SelectContext(ctx, &items, query, args...); err != nil { return nil, err } return items, 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 } // 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, role TEXT NOT NULL, cnpj TEXT NOT NULL UNIQUE, corporate_name TEXT NOT NULL, license_number TEXT NOT NULL, is_verified BOOLEAN NOT NULL DEFAULT FALSE, 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, 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) ); ` if _, err := r.db.ExecContext(ctx, schema); err != nil { return fmt.Errorf("apply schema: %w", err) } return nil }