Partial implementation of multi-receipt querys

Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
This commit is contained in:
Ethan Wellenreiter 2025-06-23 13:15:22 -04:00
parent a22bbbf5ab
commit 47ff895b26
5 changed files with 261 additions and 12 deletions

View File

@ -5,20 +5,42 @@ import (
"net/http"
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
"git.ewellenr.ca/receipt_indexer/backend/internal/storage/cache"
)
type receiptKey string
const receiptCtx receiptKey = "receipt"
type receiptsQuery struct {
Length int `json:"length"`
List []*storage.Receipt `json:"list"`
}
func (app *application) getReceiptsHandler(w http.ResponseWriter, r *http.Request) {
// get the page and size from context
// default them to something
receipts, err := app.store.Receipts.GetForUser(r.Context(), getUserFromContext(r).ID)
if err != nil {
app.internalServerError(w, r, err)
return
}
if err := app.jsonResponse(w, http.StatusOK, &receiptsQuery{Length: len(receipts), List: receipts}); err != nil {
app.internalServerError(w, r, err)
}
}
func (app *application) getReceiptHandler(w http.ResponseWriter, r *http.Request) {
receipt, err := app.store.Receipts.GetByID(r.Context())
if err != nil {
app.internalServerError(w, r, err)
return
}
if err := app.jsonResponse(w, http.StatusOK, &receiptsQuery{Length: len(receipts), List: receipts}); err != nil {
app.internalServerError(w, r, err)
}
}
func (app *application) createReceiptHandler(w http.ResponseWriter, r *http.Request) {
@ -41,7 +63,7 @@ func (app *application) deleteReceiptHandler(w http.ResponseWriter, r *http.Requ
}
func (app *application) getReceipt(ctx context.Context, receiptID int64) (*storage.Receipt, error) {
if !app.config.redisCfg.enabled {
if !app.config.cacheCfg.enabled {
return app.store.Receipts.GetByID(ctx, receiptID)
}
@ -64,6 +86,36 @@ func (app *application) getReceipt(ctx context.Context, receiptID int64) (*stora
return receipt, nil
}
func (app *application) getReceipts(ctx context.Context, userID int64) ([]*storage.Receipt, error) {
if !app.config.cacheCfg.enabled {
return app.store.Receipts.GetForUser(ctx, userID)
}
var queryID int64 = 0 //encode it here
receiptIDs, err := app.cacheStorage.ReceiptList.Get(ctx, queryID)
if err != nil {
return nil, err
}
if receiptIDs == nil {
receipts, err := app.store.Receipts.GetForUser(ctx, userID)
if err != nil {
return nil, err
}
receiptIDs = []int64{}
for _, val := range receipts {
receiptIDs = append(receiptIDs, val.ID)
}
if err := app.cacheStorage.ReceiptList.Set(ctx, cache.ReceiptListKeyVal{ID: queryID, List: receiptIDs}); err != nil {
return nil, err
}
}
return receipt, nil
}
func getReceiptFromContext(r *http.Request) *storage.Receipt {
receipt, _ := r.Context().Value(receiptCtx).(*storage.Receipt)
return receipt

View File

@ -9,12 +9,13 @@ import (
)
const (
UserExpTime = time.Minute
RoleExpTime = time.Minute
GroupExpTime = time.Minute
UserGroupsExpTime = time.Minute
ReceiptExpTime = time.Minute
ReceiptImageExpTime = time.Minute
UserExpTime = time.Minute
RoleExpTime = time.Minute
GroupExpTime = time.Minute
UserGroupsExpTime = time.Minute
ReceiptExpTime = time.Minute * 5
ReceiptsQueryExpTime = time.Minute
ReceiptImageExpTime = time.Minute
)
type Storage struct {
@ -43,6 +44,11 @@ type Storage struct {
Set(ctx context.Context, receipt *storage.Receipt) error
Delete(ctx context.Context, id int64)
}
ReceiptList interface {
Get(ctx context.Context, id int64) ([]int64, error)
Set(ctx context.Context, receiptidList ReceiptListKeyVal) error
Delete(ctx context.Context, id int64)
}
ReceiptImage interface {
Get(ctx context.Context, id int64) (*storage.Image, error)
Set(ctx context.Context, image *storage.Image) error

View File

@ -0,0 +1,59 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"github.com/redis/go-redis/v9"
)
type ReceiptListKeyVal struct {
ID int64 `json:"id"`
List []int64 `json:"list"`
}
type ReceiptListStore struct {
rdb *redis.Client
}
func (s *ReceiptListStore) generateCacheKey(id int64) string {
return fmt.Sprintf("receiptList-%d", id)
}
func (s *ReceiptListStore) Get(ctx context.Context, id int64) ([]int64, error) {
cacheKey := s.generateCacheKey(id)
data, err := s.rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
return nil, nil
} else if err != nil {
return nil, err
}
var receipts ReceiptListKeyVal
if data != "" {
err := json.Unmarshal([]byte(data), &receipts)
if err != nil {
return nil, err
}
}
return receipts.List, nil
}
func (s *ReceiptListStore) Set(ctx context.Context, receiptidList ReceiptListKeyVal) error {
cacheKey := s.generateCacheKey(receiptidList.ID)
json, err := json.Marshal(receiptidList)
if err != nil {
return err
}
return s.rdb.Set(ctx, cacheKey, json, ReceiptsQueryExpTime).Err()
}
func (s *ReceiptListStore) Delete(ctx context.Context, id int64) {
cacheKey := s.generateCacheKey(id)
s.rdb.Del(ctx, cacheKey)
}

View File

@ -1,6 +1,15 @@
package storage
import "time"
import (
"context"
"encoding/base64"
"encoding/json"
"net/url"
"strconv"
"time"
l_context "git.ewellenr.ca/receipt_indexer/backend/internal/context"
)
type Receipt struct {
ID int64 `json:"id"`
@ -19,3 +28,123 @@ type ReceiptData struct {
Items map[string]float64 `json:"items"`
// Currency string `json:"currency"`
}
type ReceiptsQuery struct {
UserID int64 `json:"user_id"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Page int `json:"page"`
Size int `json:"size"`
SearchTerms []string `json:"search_terms"`
MinTotal int `json:"min_amount"`
MaxTotal int `json:"max_amount"`
}
var (
pageNumberKey = "page"
pageSizeKey = "size"
timeStartKey = "time_start"
timeEndKey = "time_end"
searchTermsKey = "search_terms"
totalMinKey = "total_min"
totalMaxKey = "total_max"
)
const stringifiedQueryMaxLength = 524288000 // 500MiB
func newReceiptQuery() ReceiptsQuery {
return ReceiptsQuery{
UserID: -1,
StartDate: time.Time{},
EndDate: time.Time{},
Page: 1,
Size: 20,
SearchTerms: nil,
MinTotal: -1,
MaxTotal: -1,
}
}
func EncodeReceiptQuery(ctx context.Context, userID int64) (string, error) {
receiptQuery := newReceiptQuery()
receiptQuery.UserID = userID
var urlParams url.Values = nil
if ctx.Value(l_context.QueryParamsCtx) != nil {
urlParams = ctx.Value(l_context.QueryParamsCtx).(url.Values)
}
if urlParams == nil {
return "", ErrInvalidReceiptsQuery
}
// Extract the search terms
if urlParams.Has(pageNumberKey) {
temp, err := strconv.Atoi(urlParams.Get(pageNumberKey))
if err != nil {
return "", err
}
receiptQuery.Page = temp
}
if urlParams.Has(pageSizeKey) {
temp, err := strconv.Atoi(urlParams.Get(pageSizeKey))
if err != nil {
return "", err
}
receiptQuery.Page = temp
}
if urlParams.Has(timeStartKey) {
temp, err := time.Parse(time.DateTime, urlParams.Get(timeStartKey))
if err != nil {
return "", err
}
receiptQuery.StartDate = temp
}
if urlParams.Has(timeEndKey) {
temp, err := time.Parse(time.DateTime, urlParams.Get(timeEndKey))
if err != nil {
return "", err
}
receiptQuery.EndDate = temp
}
if urlParams.Has(totalMinKey) {
temp, err := strconv.Atoi(urlParams.Get(totalMinKey))
if err != nil {
return "", err
}
receiptQuery.MinTotal = temp
}
if urlParams.Has(totalMaxKey) {
temp, err := strconv.Atoi(urlParams.Get(totalMaxKey))
if err != nil {
return "", err
}
receiptQuery.MaxTotal = temp
}
if urlParams.Has(searchTermsKey) {
temp := urlParams[searchTermsKey]
receiptQuery.SearchTerms = temp
}
// encoding the query into a string so that it can be used as a query term
jsonEncoding, err := json.Marshal(receiptQuery)
if err != nil {
return "", err
}
out := base64.StdEncoding.EncodeToString(jsonEncoding)
// convert userID to base64 encoding of the int (so bitwise of the int and then into base64)
if len(out) > stringifiedQueryMaxLength { // Makes the assumption that none of the other filters would cause the overflow of 500MiB
return "", ErrSearchTermTooLong
}
return out, nil
}

View File

@ -8,10 +8,12 @@ import (
)
var (
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrDuplicateEmail = errors.New("a user with that email already exists")
ErrDuplicateUsername = errors.New("a user with that username already exists")
ErrNotFound = errors.New("resource not found")
ErrConflict = errors.New("resource already exists")
ErrDuplicateEmail = errors.New("a user with that email already exists")
ErrDuplicateUsername = errors.New("a user with that username already exists")
ErrInvalidReceiptsQuery = errors.New("invalid receipts query")
ErrSearchTermTooLong = errors.New("search term too long")
)
type Storage struct {
@ -62,6 +64,7 @@ type Storage struct {
GetByID(ctx context.Context, id int64) (*Receipt, error)
Create(ctx context.Context, receipt *Receipt) error
Delete(ctx context.Context, id int64) error
GetForUser(ctx context.Context, userid int64) ([]*Receipt, error)
}
Images interface {
GetByID(context.Context, int64) (*Image, error)