Add initial backend implementation
- Go module with Gin, GORM, JWT, excelize dependencies - Configuration loading from YAML with all settings - GORM models for users, categories, components, configurations, alerts - Repository layer for all entities - Services: auth (JWT), pricing (median/average/weighted), components, quotes, configurations, export (CSV/XLSX), alerts - Middleware: JWT auth, role-based access, CORS - HTTP handlers for all API endpoints - Main server with dependency injection and graceful shutdown Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
a4ab83d4d9
commit
190a9aa0a3
@@ -0,0 +1,91 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AlertRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAlertRepository(db *gorm.DB) *AlertRepository {
|
||||
return &AlertRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Create(alert *models.PricingAlert) error {
|
||||
return r.db.Create(alert).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) GetByID(id uint) (*models.PricingAlert, error) {
|
||||
var alert models.PricingAlert
|
||||
err := r.db.First(&alert, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &alert, nil
|
||||
}
|
||||
|
||||
func (r *AlertRepository) Update(alert *models.PricingAlert) error {
|
||||
return r.db.Save(alert).Error
|
||||
}
|
||||
|
||||
type AlertFilter struct {
|
||||
Status models.AlertStatus
|
||||
Severity models.AlertSeverity
|
||||
Type models.AlertType
|
||||
LotName string
|
||||
}
|
||||
|
||||
func (r *AlertRepository) List(filter AlertFilter, offset, limit int) ([]models.PricingAlert, int64, error) {
|
||||
var alerts []models.PricingAlert
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.PricingAlert{})
|
||||
|
||||
if filter.Status != "" {
|
||||
query = query.Where("status = ?", filter.Status)
|
||||
}
|
||||
if filter.Severity != "" {
|
||||
query = query.Where("severity = ?", filter.Severity)
|
||||
}
|
||||
if filter.Type != "" {
|
||||
query = query.Where("alert_type = ?", filter.Type)
|
||||
}
|
||||
if filter.LotName != "" {
|
||||
query = query.Where("lot_name = ?", filter.LotName)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
err := query.
|
||||
Order("FIELD(severity, 'critical', 'high', 'medium', 'low')").
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&alerts).Error
|
||||
|
||||
return alerts, total, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) CountByStatus(status models.AlertStatus) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("status = ?", status).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AlertRepository) UpdateStatus(id uint, status models.AlertStatus) error {
|
||||
return r.db.Model(&models.PricingAlert{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
func (r *AlertRepository) ExistsByLotAndType(lotName string, alertType models.AlertType) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PricingAlert{}).
|
||||
Where("lot_name = ? AND alert_type = ? AND status IN ('new', 'acknowledged')", lotName, alertType).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CategoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
|
||||
var categories []models.Category
|
||||
err := r.db.Order("display_order ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.Where("code = ?", code).First(&category).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
|
||||
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
|
||||
var category models.Category
|
||||
err := r.db.First(&category, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &category, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ComponentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
|
||||
return &ComponentRepository{db: db}
|
||||
}
|
||||
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Vendor string
|
||||
Search string
|
||||
HasPrice bool
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
|
||||
var components []models.LotMetadata
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.LotMetadata{}).
|
||||
Preload("Lot").
|
||||
Preload("Category")
|
||||
|
||||
if filter.Category != "" {
|
||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||
Where("qt_categories.code = ?", filter.Category)
|
||||
}
|
||||
if filter.Vendor != "" {
|
||||
query = query.Where("vendor = ?", filter.Vendor)
|
||||
}
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
|
||||
}
|
||||
if filter.HasPrice {
|
||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
// Sort by popularity + freshness, no price goes last
|
||||
err := query.
|
||||
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
|
||||
Order("popularity_score DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&components).Error
|
||||
|
||||
return components, total, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
|
||||
var component models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name = ?", lotName).
|
||||
First(&component).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &component, nil
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
|
||||
var components []models.LotMetadata
|
||||
err := r.db.
|
||||
Preload("Lot").
|
||||
Preload("Category").
|
||||
Where("lot_name IN ?", lotNames).
|
||||
Find(&components).Error
|
||||
return components, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
|
||||
return r.db.Save(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
|
||||
return r.db.Create(component).Error
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) GetVendors(category string) ([]string, error) {
|
||||
var vendors []string
|
||||
query := r.db.Model(&models.LotMetadata{}).Distinct("vendor")
|
||||
if category != "" {
|
||||
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
|
||||
Where("qt_categories.code = ?", category)
|
||||
}
|
||||
err := query.Pluck("vendor", &vendors).Error
|
||||
return vendors, err
|
||||
}
|
||||
|
||||
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.LotMetadata{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + 1"),
|
||||
"last_request_date": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetAllLots returns all lots from the existing lot table
|
||||
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
|
||||
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
|
||||
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
|
||||
var lots []models.Lot
|
||||
err := r.db.
|
||||
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
|
||||
Find(&lots).Error
|
||||
return lots, err
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigurationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
|
||||
return &ConfigurationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
|
||||
return r.db.Create(config).Error
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").First(&config, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration, error) {
|
||||
var config models.Configuration
|
||||
err := r.db.Preload("User").Where("uuid = ?", uuid).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
|
||||
return r.db.Save(config).Error
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Configuration{}, id).Error
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) ListByUser(userID uint, offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("user_id = ?", userID).Count(&total)
|
||||
err := r.db.
|
||||
Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
|
||||
func (r *ConfigurationRepository) ListTemplates(offset, limit int) ([]models.Configuration, int64, error) {
|
||||
var configs []models.Configuration
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.Configuration{}).Where("is_template = ?", true).Count(&total)
|
||||
err := r.db.
|
||||
Preload("User").
|
||||
Where("is_template = ?", true).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&configs).Error
|
||||
|
||||
return configs, total, err
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PriceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPriceRepository(db *gorm.DB) *PriceRepository {
|
||||
return &PriceRepository{db: db}
|
||||
}
|
||||
|
||||
type PricePoint struct {
|
||||
Price float64
|
||||
Date time.Time
|
||||
}
|
||||
|
||||
// GetPriceHistory returns price history from lot_log for a component
|
||||
func (r *PriceRepository) GetPriceHistory(lotName string, periodDays int) ([]PricePoint, error) {
|
||||
var points []PricePoint
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Order("date DESC").
|
||||
Scan(&points).Error
|
||||
|
||||
return points, err
|
||||
}
|
||||
|
||||
// GetLatestPrice returns the most recent price for a component
|
||||
func (r *PriceRepository) GetLatestPrice(lotName string) (*PricePoint, error) {
|
||||
var point PricePoint
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Select("price, date").
|
||||
Where("lot = ?", lotName).
|
||||
Order("date DESC").
|
||||
First(&point).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &point, nil
|
||||
}
|
||||
|
||||
// GetPriceOverride returns active override for a component
|
||||
func (r *PriceRepository) GetPriceOverride(lotName string) (*models.PriceOverride, error) {
|
||||
var override models.PriceOverride
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Where("valid_from <= ?", today).
|
||||
Where("valid_until IS NULL OR valid_until >= ?", today).
|
||||
Order("valid_from DESC").
|
||||
First(&override).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &override, nil
|
||||
}
|
||||
|
||||
// CreatePriceOverride creates a new price override
|
||||
func (r *PriceRepository) CreatePriceOverride(override *models.PriceOverride) error {
|
||||
return r.db.Create(override).Error
|
||||
}
|
||||
|
||||
// GetPriceOverrides returns all overrides for a component
|
||||
func (r *PriceRepository) GetPriceOverrides(lotName string) ([]models.PriceOverride, error) {
|
||||
var overrides []models.PriceOverride
|
||||
err := r.db.
|
||||
Where("lot_name = ?", lotName).
|
||||
Order("valid_from DESC").
|
||||
Find(&overrides).Error
|
||||
return overrides, err
|
||||
}
|
||||
|
||||
// DeletePriceOverride deletes an override
|
||||
func (r *PriceRepository) DeletePriceOverride(id uint) error {
|
||||
return r.db.Delete(&models.PriceOverride{}, id).Error
|
||||
}
|
||||
|
||||
// GetQuoteCount returns the number of quotes in lot_log for a period
|
||||
func (r *PriceRepository) GetQuoteCount(lotName string, periodDays int) (int64, error) {
|
||||
var count int64
|
||||
since := time.Now().AddDate(0, 0, -periodDays)
|
||||
|
||||
err := r.db.Model(&models.LotLog{}).
|
||||
Where("lot = ? AND date >= ?", lotName, since).
|
||||
Count(&count).Error
|
||||
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type StatsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewStatsRepository(db *gorm.DB) *StatsRepository {
|
||||
return &StatsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetByLotName(lotName string) (*models.ComponentUsageStats, error) {
|
||||
var stats models.ComponentUsageStats
|
||||
err := r.db.Where("lot_name = ?", lotName).First(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *StatsRepository) Upsert(stats *models.ComponentUsageStats) error {
|
||||
return r.db.Save(stats).Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) IncrementUsage(lotName string, quantity int, revenue float64) error {
|
||||
now := time.Now()
|
||||
|
||||
result := r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("lot_name = ?", lotName).
|
||||
Updates(map[string]interface{}{
|
||||
"quotes_total": gorm.Expr("quotes_total + 1"),
|
||||
"quotes_last_30d": gorm.Expr("quotes_last_30d + 1"),
|
||||
"quotes_last_7d": gorm.Expr("quotes_last_7d + 1"),
|
||||
"total_quantity": gorm.Expr("total_quantity + ?", quantity),
|
||||
"total_revenue": gorm.Expr("total_revenue + ?", revenue),
|
||||
"last_used_at": now,
|
||||
})
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
stats := &models.ComponentUsageStats{
|
||||
LotName: lotName,
|
||||
QuotesTotal: 1,
|
||||
QuotesLast30d: 1,
|
||||
QuotesLast7d: 1,
|
||||
TotalQuantity: quantity,
|
||||
TotalRevenue: revenue,
|
||||
LastUsedAt: &now,
|
||||
}
|
||||
return r.db.Create(stats).Error
|
||||
}
|
||||
|
||||
return result.Error
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTopComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Order("quotes_last_30d DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func (r *StatsRepository) GetTrendingComponents(limit int) ([]models.ComponentUsageStats, error) {
|
||||
var stats []models.ComponentUsageStats
|
||||
err := r.db.
|
||||
Where("trend_direction = ? AND trend_percent > ?", models.TrendUp, 20).
|
||||
Order("trend_percent DESC").
|
||||
Limit(limit).
|
||||
Find(&stats).Error
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// ResetWeeklyCounters resets quotes_last_7d (run weekly via cron)
|
||||
func (r *StatsRepository) ResetWeeklyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_7d", 0).Error
|
||||
}
|
||||
|
||||
// ResetMonthlyCounters resets quotes_last_30d (run monthly via cron)
|
||||
func (r *StatsRepository) ResetMonthlyCounters() error {
|
||||
return r.db.Model(&models.ComponentUsageStats{}).
|
||||
Where("1 = 1").
|
||||
Update("quotes_last_30d", 0).Error
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("username = ?", username).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
|
||||
var users []models.User
|
||||
var total int64
|
||||
|
||||
r.db.Model(&models.User{}).Count(&total)
|
||||
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
Reference in New Issue
Block a user