package services import ( "context" "database/sql" "fmt" "github.com/google/uuid" "github.com/jmoiron/sqlx" "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" ) type TimeEntryService struct { db *sqlx.DB audit *AuditService } func NewTimeEntryService(db *sqlx.DB, audit *AuditService) *TimeEntryService { return &TimeEntryService{db: db, audit: audit} } type CreateTimeEntryInput struct { CaseID uuid.UUID `json:"case_id"` Date string `json:"date"` DurationMinutes int `json:"duration_minutes"` Description string `json:"description"` Activity *string `json:"activity,omitempty"` Billable *bool `json:"billable,omitempty"` HourlyRate *float64 `json:"hourly_rate,omitempty"` } type UpdateTimeEntryInput struct { Date *string `json:"date,omitempty"` DurationMinutes *int `json:"duration_minutes,omitempty"` Description *string `json:"description,omitempty"` Activity *string `json:"activity,omitempty"` Billable *bool `json:"billable,omitempty"` HourlyRate *float64 `json:"hourly_rate,omitempty"` } type TimeEntryFilter struct { CaseID *uuid.UUID UserID *uuid.UUID From string To string Limit int Offset int } type TimeEntrySummary struct { GroupKey string `db:"group_key" json:"group_key"` TotalMinutes int `db:"total_minutes" json:"total_minutes"` BillableMinutes int `db:"billable_minutes" json:"billable_minutes"` TotalAmount float64 `db:"total_amount" json:"total_amount"` EntryCount int `db:"entry_count" json:"entry_count"` } const timeEntryCols = `id, tenant_id, case_id, user_id, date, duration_minutes, description, activity, billable, billed, invoice_id, hourly_rate, created_at, updated_at` func (s *TimeEntryService) ListForCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.TimeEntry, error) { var entries []models.TimeEntry err := s.db.SelectContext(ctx, &entries, `SELECT `+timeEntryCols+` FROM time_entries WHERE tenant_id = $1 AND case_id = $2 ORDER BY date DESC, created_at DESC`, tenantID, caseID) if err != nil { return nil, fmt.Errorf("list time entries for case: %w", err) } return entries, nil } func (s *TimeEntryService) List(ctx context.Context, tenantID uuid.UUID, filter TimeEntryFilter) ([]models.TimeEntry, int, error) { if filter.Limit <= 0 { filter.Limit = 20 } if filter.Limit > 100 { filter.Limit = 100 } where := "WHERE tenant_id = $1" args := []any{tenantID} argIdx := 2 if filter.CaseID != nil { where += fmt.Sprintf(" AND case_id = $%d", argIdx) args = append(args, *filter.CaseID) argIdx++ } if filter.UserID != nil { where += fmt.Sprintf(" AND user_id = $%d", argIdx) args = append(args, *filter.UserID) argIdx++ } if filter.From != "" { where += fmt.Sprintf(" AND date >= $%d", argIdx) args = append(args, filter.From) argIdx++ } if filter.To != "" { where += fmt.Sprintf(" AND date <= $%d", argIdx) args = append(args, filter.To) argIdx++ } var total int err := s.db.GetContext(ctx, &total, "SELECT COUNT(*) FROM time_entries "+where, args...) if err != nil { return nil, 0, fmt.Errorf("count time entries: %w", err) } query := fmt.Sprintf("SELECT %s FROM time_entries %s ORDER BY date DESC, created_at DESC LIMIT $%d OFFSET $%d", timeEntryCols, where, argIdx, argIdx+1) args = append(args, filter.Limit, filter.Offset) var entries []models.TimeEntry err = s.db.SelectContext(ctx, &entries, query, args...) if err != nil { return nil, 0, fmt.Errorf("list time entries: %w", err) } return entries, total, nil } func (s *TimeEntryService) GetByID(ctx context.Context, tenantID, entryID uuid.UUID) (*models.TimeEntry, error) { var entry models.TimeEntry err := s.db.GetContext(ctx, &entry, `SELECT `+timeEntryCols+` FROM time_entries WHERE tenant_id = $1 AND id = $2`, tenantID, entryID) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get time entry: %w", err) } return &entry, nil } func (s *TimeEntryService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateTimeEntryInput) (*models.TimeEntry, error) { billable := true if input.Billable != nil { billable = *input.Billable } // If no hourly rate provided, look up the current billing rate hourlyRate := input.HourlyRate if hourlyRate == nil { var rate float64 err := s.db.GetContext(ctx, &rate, `SELECT rate FROM billing_rates WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL) AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3) ORDER BY user_id NULLS LAST LIMIT 1`, tenantID, userID, input.Date) if err == nil { hourlyRate = &rate } } var entry models.TimeEntry err := s.db.QueryRowxContext(ctx, `INSERT INTO time_entries (tenant_id, case_id, user_id, date, duration_minutes, description, activity, billable, hourly_rate) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING `+timeEntryCols, tenantID, input.CaseID, userID, input.Date, input.DurationMinutes, input.Description, input.Activity, billable, hourlyRate, ).StructScan(&entry) if err != nil { return nil, fmt.Errorf("create time entry: %w", err) } s.audit.Log(ctx, "create", "time_entry", &entry.ID, nil, entry) return &entry, nil } func (s *TimeEntryService) Update(ctx context.Context, tenantID, entryID uuid.UUID, input UpdateTimeEntryInput) (*models.TimeEntry, error) { old, err := s.GetByID(ctx, tenantID, entryID) if err != nil { return nil, err } if old == nil { return nil, fmt.Errorf("time entry not found") } if old.Billed { return nil, fmt.Errorf("cannot update a billed time entry") } var entry models.TimeEntry err = s.db.QueryRowxContext(ctx, `UPDATE time_entries SET date = COALESCE($3, date), duration_minutes = COALESCE($4, duration_minutes), description = COALESCE($5, description), activity = COALESCE($6, activity), billable = COALESCE($7, billable), hourly_rate = COALESCE($8, hourly_rate), updated_at = now() WHERE tenant_id = $1 AND id = $2 RETURNING `+timeEntryCols, tenantID, entryID, input.Date, input.DurationMinutes, input.Description, input.Activity, input.Billable, input.HourlyRate, ).StructScan(&entry) if err != nil { return nil, fmt.Errorf("update time entry: %w", err) } s.audit.Log(ctx, "update", "time_entry", &entry.ID, old, entry) return &entry, nil } func (s *TimeEntryService) Delete(ctx context.Context, tenantID, entryID uuid.UUID) error { old, err := s.GetByID(ctx, tenantID, entryID) if err != nil { return err } if old == nil { return fmt.Errorf("time entry not found") } if old.Billed { return fmt.Errorf("cannot delete a billed time entry") } _, err = s.db.ExecContext(ctx, `DELETE FROM time_entries WHERE tenant_id = $1 AND id = $2`, tenantID, entryID) if err != nil { return fmt.Errorf("delete time entry: %w", err) } s.audit.Log(ctx, "delete", "time_entry", &entryID, old, nil) return nil } func (s *TimeEntryService) Summary(ctx context.Context, tenantID uuid.UUID, groupBy string, from, to string) ([]TimeEntrySummary, error) { var groupExpr string switch groupBy { case "user": groupExpr = "user_id::text" case "month": groupExpr = "to_char(date, 'YYYY-MM')" default: groupExpr = "case_id::text" } where := "WHERE tenant_id = $1" args := []any{tenantID} argIdx := 2 if from != "" { where += fmt.Sprintf(" AND date >= $%d", argIdx) args = append(args, from) argIdx++ } if to != "" { where += fmt.Sprintf(" AND date <= $%d", argIdx) args = append(args, to) argIdx++ } query := fmt.Sprintf(`SELECT %s AS group_key, SUM(duration_minutes) AS total_minutes, SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END) AS billable_minutes, SUM(CASE WHEN billable AND hourly_rate IS NOT NULL THEN duration_minutes * hourly_rate / 60.0 ELSE 0 END) AS total_amount, COUNT(*) AS entry_count FROM time_entries %s GROUP BY %s ORDER BY %s`, groupExpr, where, groupExpr, groupExpr) var summaries []TimeEntrySummary err := s.db.SelectContext(ctx, &summaries, query, args...) if err != nil { return nil, fmt.Errorf("time entry summary: %w", err) } return summaries, nil }