Compare commits

...

6 Commits

Author SHA1 Message Date
d1a267eb44 Slight update to DB scheme
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:16:40 -04:00
47ff895b26 Partial implementation of multi-receipt querys
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:15:28 -04:00
a22bbbf5ab Adding function signatures for API handlers
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:11:35 -04:00
9b9305db29 Updating config and application struct and modifying api paths
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:10:22 -04:00
b687b43b35 Implementing Middleware for adding query params and groupID to context
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:09:41 -04:00
18330e745f Adding context variables to the internal section
Signed-off-by: Ethan Wellenreiter <ewellenreiter@gmail.com>
2025-06-23 13:08:23 -04:00
11 changed files with 392 additions and 37 deletions

View File

@ -14,33 +14,43 @@ import (
"github.com/go-chi/httprate"
// "git.ewellenr.ca/receipt_indexer/backend/internal/auth"
"git.ewellenr.ca/receipt_indexer/backend/internal/env"
"git.ewellenr.ca/receipt_indexer/backend/internal/logger"
"git.ewellenr.ca/receipt_indexer/backend/internal/ratelimiter"
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
"git.ewellenr.ca/receipt_indexer/backend/internal/storage/cache"
)
type application struct {
// set up the configs stuff here
config config
auth auth_storage.AuthStorage
store storage.Storage
logger logger.Logger
cacheStorage cache.Storage
rateLimiter ratelimiter.Limiter
environment env.Environment
store storage.Storage // for user storage. also stores authentication stuff
logger logger.Logger // logging events/actions/issues
cacheStorage cache.Storage // for caching the storage
rateLimiter ratelimiter.Limiter // rate limiting users
environment env.Environment // accessing environment variables for the setup
}
type config struct {
addr string
storeCfg storeConfig
envCfg envConfig
apiUrl string
frontendUrl string
auth authConfig
mail mailConfig
cacheCfg redisConfig
rateLimiter ratelimiter.Config
redisCfg redisConfig
// holds the different stuff like rate limiter, store, authenticator
}
type storeConfig struct {
db dbConfig
authStore authConfig
}
type redisConfig struct {
addr string
pw string
@ -108,36 +118,49 @@ func (app *application) mount() http.Handler {
r.Route("/{groupID}", func(r chi.Router) {
r.Use(app.addGroupToContextMiddleware)
r.Get("/", app.getUsersGroupHandler)
r.Delete("/", app.removeUserGroupHandler) // maybe this should expect authentication headers to reverify the password when deleting a group you own.
r.Put("/owner", app.setGroupOwnerHandler)
r.Get("/owner", app.getGroupOwnerHandler)
r.Post("/moderator", app.addGroupModeratorHandler)
r.Put("/moderator", app.addGroupModeratorHandler)
r.Delete("/moderator/{secondaryuserID}", app.removeModeratorPriviligesHandler)
r.Get("/users", app.getGroupUsersHandler)
r.Post("/users", app.addGroupUserHandler) // needs to create a new user/do the whole invite thing if they don't already exist
r.Delete("/users/{secondaryuserID}", app.removeUserFromGroupHandler) // needs to check if the user is an owner/moderator
r.Route("/users", func(r chi.Router) {
r.Use(app.CheckGroupRoleMiddleware) // need to verify that they are an owner/moderator
r.Get("/", app.getGroupUsersHandler)
r.Post("/", app.addGroupUserHandler) // needs to create a new user/do the whole invite thing if they don't already exist
r.Delete("/{secondaryuserID}", app.removeUserFromGroupHandler) // needs to check if the user is an owner/moderator
})
})
})
r.Route("/receipts", func(r chi.Router) {
r.Get("/", app.getReceiptsHandler)
r.Use(app.addQueryParamsToContextMiddleware)
r.Get("/", app.getReceiptsHandler) // batch get. should allow pagination and potentially a filter of some sort in the request. Does not return images or anything like that. Just the basic data (id, amount, date, location/store)
r.Route("/{receiptID}", func(r chi.Router) {
r.Use(app.receiptContextMiddleware)
r.Get("/", app.getReceiptHandler)
r.Delete("/", app.deleteReceiptHandler)
r.Route("/images", func(r chi.Router) {
r.Get("/", app.getReceiptImagesHandler)
r.Put("/", app.addReceiptImageHandler)
r.Post("/", app.addReceiptImageHandler)
r.Route("/{imageID}", func(r chi.Router) {
r.Get("/", app.getReceiptImageHandler)
r.Put("/", app.changeReceiptImageHandler)
r.Delete("/", app.deleteReceiptImageHandler)
r.Post("/confirm", app.confirmImageCreationHandler)
r.Put("/confirm", app.confirmImageCreationHandler)
})
})
})

View File

@ -9,8 +9,12 @@ import (
"github.com/go-chi/chi/v5"
)
type groupKey string
const groupCtx groupKey = "group"
func (app *application) getUserGroups(ctx context.Context, userID int64) (*storage.UserGroups, error) {
if !app.config.redisCfg.enabled {
if !app.config.cacheCfg.enabled {
return app.store.Groups.GetUserGroups(ctx, userID)
}
@ -34,7 +38,7 @@ func (app *application) getUserGroups(ctx context.Context, userID int64) (*stora
}
func (app *application) getGroup(ctx context.Context, groupID int64) (*storage.Group, error) {
if !app.config.redisCfg.enabled {
if !app.config.cacheCfg.enabled {
return app.store.Groups.GetByID(ctx, groupID)
}
@ -104,3 +108,24 @@ func (app *application) getUsersGroupHandler(w http.ResponseWriter, r *http.Requ
app.internalServerError(w, r, err)
}
}
func (app *application) setGroupOwnerHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) getGroupOwnerHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) addGroupModeratorHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) removeModeratorPriviligesHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) getGroupUsersHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) addGroupUserHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) removeUserFromGroupHandler(w http.ResponseWriter, r *http.Request) {
}

View File

@ -7,7 +7,8 @@ import (
"net/http"
"strconv"
auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
// auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
l_context "git.ewellenr.ca/receipt_indexer/backend/internal/context"
"github.com/go-chi/chi/v5"
)
@ -27,7 +28,7 @@ func (app *application) AuthSessionMiddleware(next http.Handler) http.Handler {
return
}
valid, userID, err := app.auth.Sessions.CheckSession(r.Context(), token) // should have a different function for this
valid, userID, err := app.store.Sessions.CheckSession(r.Context(), token) // should have a different function for this
if !valid {
app.unauthorizedErrorResponse(w, r, fmt.Errorf("Invalid session token"))
return
@ -131,7 +132,7 @@ func (app *application) RateLimiterMiddleware(next http.Handler) http.Handler {
})
}
func (app *application) receiptsContextMiddleware(next http.Handler) http.Handler {
func (app *application) receiptContextMiddleware(next http.Handler) http.Handler {
// add the receipt id to the context? or the receipt class to the context
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -163,7 +164,7 @@ func (app *application) receiptsContextMiddleware(next http.Handler) http.Handle
}
func (app *application) checkRolePrecedence(ctx context.Context, user *auth_storage.User, roleName string) (bool, error) {
role, err := app.auth.Roles.GetByName(ctx, roleName)
role, err := app.store.Roles.GetByName(ctx, roleName)
if err != nil {
return false, err
}
@ -195,3 +196,36 @@ func (app *application) checkReceiptOwnership(requiredRole string, next http.Han
next.ServeHTTP(w, r)
})
}
func (app *application) addGroupToContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
urlGroupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
if err != nil {
app.badRequestResponse(w, r, fmt.Errorf("Invalid url group ID - Not an integer"))
return
}
ctx := r.Context()
group, err := app.getGroup(ctx, urlGroupID)
if err != nil {
app.unauthorizedErrorResponse(w, r, err)
return
}
ctx = context.WithValue(ctx, groupCtx, group)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (app *application) addQueryParamsToContextMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// need to select the user from the token validation stuff
ctx := r.Context()
ctx = context.WithValue(ctx, l_context.QueryParamsCtx, r.URL.Query())
// make sure to add user and role into the context here
next.ServeHTTP(w, r.WithContext(ctx))
})
}

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

@ -51,9 +51,12 @@ func (app *application) getUsersHandler(w http.ResponseWriter, r *http.Request)
func (app *application) deleteUserHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) removeUserGroupHandler(w http.ResponseWriter, r *http.Request) {
}
func (app *application) getUser(ctx context.Context, userID int64) (*storage.User, error) {
if !app.config.redisCfg.enabled {
return app.auth.Users.GetByID(ctx, userID)
if !app.config.cacheCfg.enabled {
return app.store.Users.GetByID(ctx, userID)
}
user, err := app.cacheStorage.Users.Get(ctx, userID)
@ -62,7 +65,7 @@ func (app *application) getUser(ctx context.Context, userID int64) (*storage.Use
}
if user == nil {
user, err = app.auth.Users.GetByID(ctx, userID)
user, err = app.store.Users.GetByID(ctx, userID)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,21 @@
package context
import "net/http"
type pageKey string
const PageCtx pageKey = "page"
type pagesizeKey string
const PagesizeCtx pagesizeKey = "pageSize"
type queryParamsKey string
const QueryParamsCtx queryParamsKey = "queryParams"
func getPageAndSizeFromContext(r *http.Request) (int, int) {
pageNumber, _ := r.Context().Value(pageCtx).(int)
pageSize, _ := r.Context().Value(pagesizeCtx).(int)
return pageNumber, pageSize
}

View File

@ -38,7 +38,7 @@ Table reciepts {
groupid integer
created_at timestamp
updated_at timestamp
data nvarchar
data jsonb
}
// Table imageOwnership {

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)