gabor-boros/minutes

View on GitHub
internal/pkg/client/harvest/harvest.go

Summary

Maintainability
A
0 mins
Test Coverage
B
83%
package harvest

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "strconv"
    "time"

    "github.com/gabor-boros/minutes/internal/pkg/client"
    "github.com/gabor-boros/minutes/internal/pkg/utils"
    "github.com/gabor-boros/minutes/internal/pkg/worklog"
)

const (
    // PathWorklog is the endpoint used to search existing worklogs.
    PathWorklog string = "/v2/time_entries"
)

// FetchEntry represents the entry fetched from Harvest.
type FetchEntry struct {
    Client    worklog.IntIDNameField `json:"client"`
    Project   worklog.IntIDNameField `json:"project"`
    Task      worklog.IntIDNameField `json:"task"`
    Notes     string                 `json:"notes"`
    SpentDate string                 `json:"spent_date"`
    Hours     float32                `json:"hours"`
    CreatedAt time.Time              `json:"created_at"`
    Billable  bool                   `json:"billable"`
    IsRunning bool                   `json:"is_running"`
}

// Start returns the start date created from the spent date and created at.
// The spent date represents the date the user wants the entry to be logged,
// e.g: 2021-10-01. The creation date represents the actual creation of the
// entry, e.g: 2021-10-02T10:26:20Z. Since Harvest is not precise with the
// spent date, we have to create a start date from these two entries. This is
// needed, because if the user is manually creating an entry, and creates on
// a wrong date accidentally, after editing the entry, the spent date will be
// updated, though the creation date not.
func (e *FetchEntry) Start() (time.Time, error) {
    spentDate, err := utils.DateFormatISO8601.Parse(e.SpentDate)
    if err != nil {
        return time.Time{}, err
    }

    return time.Date(
        spentDate.Year(),
        spentDate.Month(),
        spentDate.Day(),
        e.CreatedAt.Hour(),
        e.CreatedAt.Minute(),
        e.CreatedAt.Second(),
        e.CreatedAt.Nanosecond(),
        e.CreatedAt.Location(),
    ), nil
}

// FetchResponse represents the relevant response data.
// Although the response contains a lot more information about pagination, it
// cannot be used with the current structure.
type FetchResponse struct {
    TimeEntries  []FetchEntry `json:"time_entries"`
    PerPage      int          `json:"per_page"`
    TotalEntries int          `json:"total_entries"`
}

// ClientOpts is the client specific options, extending client.BaseClientOpts.
type ClientOpts struct {
    client.BaseClientOpts
    client.TokenAuth
    BaseURL string
    Account int
}

type harvestClient struct {
    *client.BaseClientOpts
    *client.HTTPClient
    authenticator client.Authenticator
    account       int
}

func (c *harvestClient) parseEntries(rawEntries interface{}, _ *client.FetchOpts) (worklog.Entries, error) {
    var entries worklog.Entries

    fetchedEntries, ok := rawEntries.([]FetchEntry)
    if !ok {
        return nil, fmt.Errorf("%v: %s", client.ErrFetchEntries, "cannot parse returned entries")
    }

    for _, fetchedEntry := range fetchedEntries {
        startDate, err := fetchedEntry.Start()
        if err != nil {
            return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
        }

        billableDuration, err := time.ParseDuration(fmt.Sprintf("%fh", fetchedEntry.Hours))
        if err != nil {
            return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
        }

        unbillableDuration := time.Duration(0)

        if !fetchedEntry.Billable {
            unbillableDuration = billableDuration
            billableDuration = 0
        }

        entries = append(entries, worklog.Entry{
            Client:             fetchedEntry.Client.ConvertToIDNameField(),
            Project:            fetchedEntry.Project.ConvertToIDNameField(),
            Task:               fetchedEntry.Task.ConvertToIDNameField(),
            Summary:            fetchedEntry.Notes,
            Notes:              fetchedEntry.Notes,
            Start:              startDate,
            BillableDuration:   billableDuration,
            UnbillableDuration: unbillableDuration,
        })
    }

    return entries, nil
}

func (c *harvestClient) fetchEntries(ctx context.Context, reqURL string) (interface{}, *client.PaginatedFetchResponse, error) {
    resp, err := c.Call(ctx, &client.HTTPRequestOpts{
        Method:  http.MethodGet,
        Url:     reqURL,
        Auth:    c.authenticator,
        Timeout: c.Timeout,
        Headers: map[string]string{
            "Harvest-Account-ID": strconv.Itoa(c.account),
        },
    })

    if err != nil {
        return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
    }

    var fetchResponse FetchResponse
    if err = json.Unmarshal(resp, &fetchResponse); err != nil {
        return nil, nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
    }

    paginatedResponse := &client.PaginatedFetchResponse{
        EntriesPerPage: fetchResponse.PerPage,
        TotalEntries:   fetchResponse.TotalEntries,
    }

    return fetchResponse.TimeEntries, paginatedResponse, err
}

func (c *harvestClient) FetchEntries(ctx context.Context, opts *client.FetchOpts) (worklog.Entries, error) {
    fetchURL, err := c.URL(PathWorklog, map[string]string{
        "from":       utils.DateFormatRFC3339UTC.Format(opts.Start),
        "to":         utils.DateFormatRFC3339UTC.Format(opts.End),
        "user_id":    opts.User,
        "is_running": strconv.FormatBool(false),
        "user_agent": "github.com/gabor-boros/minutes",
    })

    if err != nil {
        return nil, fmt.Errorf("%v: %v", client.ErrFetchEntries, err)
    }

    return c.PaginatedFetch(ctx, &client.PaginatedFetchOpts{
        URL:       fetchURL,
        FetchFunc: c.fetchEntries,
        ParseFunc: c.parseEntries,
    })
}

// NewFetcher returns a new Clockify client for fetching entries.
func NewFetcher(opts *ClientOpts) (client.Fetcher, error) {
    baseURL, err := url.Parse(opts.BaseURL)
    if err != nil {
        return nil, err
    }

    authenticator, err := client.NewTokenAuth(opts.Header, opts.TokenName, opts.Token)
    if err != nil {
        return nil, err
    }

    return &harvestClient{
        BaseClientOpts: &opts.BaseClientOpts,
        HTTPClient: &client.HTTPClient{
            BaseURL: baseURL,
        },
        authenticator: authenticator,
        account:       opts.Account,
    }, nil
}