Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d783b1c8 | |||
| 7effe9daa4 | |||
| 10da175694 | |||
| 7e813fa3f8 | |||
| d1a267eb44 | |||
| 47ff895b26 | |||
| a22bbbf5ab | |||
| 9b9305db29 | |||
| b687b43b35 | |||
| 18330e745f | |||
| 330d850790 | |||
| 5e6d061330 | |||
| 91cfa901ca | |||
| 6a949ae682 | |||
| 780b646d68 | |||
| 30b4292c4a | |||
| c91c784338 | |||
| c34c3dfa51 | |||
| 4fd63574c2 | |||
| d3f03f2cae | |||
| d8b2bd7226 | |||
| 2b2fa217d6 | |||
| 064faeadca | |||
| a99ef367c7 | |||
| 29bf0bec88 | |||
| 3bf0d93af2 | |||
| 03fbe92c7f | |||
| 2f328ec8ee | |||
| dbd31faa6c | |||
| dbd273f2d6 |
53
TODO.md
Normal file
53
TODO.md
Normal file
@ -0,0 +1,53 @@
|
||||
1. Figure out the different structures and objects. receipts. receipt images. groups, so on.
|
||||
2. Complete the api design
|
||||
3. figure out the inbetween logic. especially for stuff that could include a transaction
|
||||
4. transactional stuff maybe should be brought up to a higher level and we can just allow a transaction type thing to be passed through them
|
||||
5. consider ignoring nosql as being an option for the system for anything other than storing receipts data maybe
|
||||
|
||||
|
||||
|
||||
ReceiptImage -> metadata, link or whatever is needed for S3 stuff
|
||||
Receipt -> Many Receipt Images, receipt data, receipt metadata
|
||||
|
||||
|
||||
Want to accomodate a single user. they can then group receipts. groups can be personal but also organization? should be able to filter which receipts are shown by personal vs organization groups? maybe by owned groups?
|
||||
a group can also have certain people who are allowed to add other people, but also adding receipts vs just viewing receipts vs only adding and seeing their own stuff? [for this, there can be roles which are strict and placed into the roles table but also stored in the groupMembership table]
|
||||
|
||||
try and implement sso? it could probably work...
|
||||
|
||||
organizations can have groups themselves. they can be set to own the groups. an organization should have one owner and then admins. admins can do everything but stuff integral to the organization like deleting it, changing the name, that stuff.
|
||||
|
||||
anyone can make an organization and add people as admins.
|
||||
|
||||
- a user does not have to be part of an organization.
|
||||
- an organization must have an owner
|
||||
- a group must have an owner
|
||||
- a receipt must be part of a group (if anything, it's the default group for the user, which is hidden)
|
||||
- a group can have admins
|
||||
- an admin cannot delete the group. a "super admin" (an organization admin) can though. super admins are at the same level as owners
|
||||
- an organization can have groups
|
||||
- an organization itself has no ability to add receipts
|
||||
- a user must add a group under an organization to begin adding receipts
|
||||
- an organization is really just a way to have multiple people acting like owners of a group and they get added automatically.
|
||||
- maybe an organization can also group groups? so like subsections or suborganizations? call them sections. sections can nest
|
||||
- a section or organization directly can have groups.
|
||||
- alternatively, we can work with the section nesting and say that an organization by default has a section covering the whole thing, kind of like a users default group.
|
||||
- a receipt cannot be part of multiple groups at once
|
||||
- make groups so that they can have subgroups (just make groups nestable)
|
||||
|
||||
- make each receipt/group have individual role based permissions for the user
|
||||
|
||||
|
||||
Figure out the permissions stuff. ACL maybe? idk
|
||||
|
||||
for the permissions, maybe something like
|
||||
|
||||
have group names be part of group names?
|
||||
|
||||
each group has a role. then that group role gets allowed to access the other stuff depending on their permissions
|
||||
|
||||
so for example, a role name could be organization::group::group::role
|
||||
|
||||
no/limited special character group names
|
||||
|
||||
set up a database migration to initialize the roles? or figure out the base roles in code
|
||||
@ -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
|
||||
@ -57,6 +67,9 @@ func (app *application) mount() http.Handler {
|
||||
r.Use(middleware.CleanPath)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
/// CSRF and SESSION TOKEN STUFF:
|
||||
// Store session token in https or whatever only so js can't access it, and the store csrf locally using react or whatever, remove the cookie, and then include it in the http header under 'X-CSRF-Token' or as a hidden input in an html form
|
||||
|
||||
r.Use(middleware.Throttle(100)) // temporary or removable. throttles the whole thing to 1000 concurrent requests
|
||||
// r.Use(cors.Handler(cors.Options{
|
||||
// AllowedOrigins: []string{env.GetString("CORS_ALLOWED_ORIGIN", "http://localhost:5174")},
|
||||
@ -96,7 +109,7 @@ func (app *application) mount() http.Handler {
|
||||
|
||||
r.Route("/user", func(r chi.Router) {
|
||||
r.Route("/{userID}", func(r chi.Router) {
|
||||
r.Use(app.AuthSessionMiddleware, app.CSRFCheckMiddleware, app.CheckUserMatchingMiddleware)
|
||||
r.Use(app.AuthSessionMiddleware, app.CSRFCheckMiddleware, app.CheckUserMatchingMiddleware) // consider the actual use of the user matching middleware and whether an admin should be used instead
|
||||
|
||||
r.Get("/", app.getUserHandler)
|
||||
|
||||
@ -104,32 +117,50 @@ func (app *application) mount() http.Handler {
|
||||
r.Get("/", app.getUsersGroupsHandler)
|
||||
|
||||
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.Put("/moderator", app.addGroupModeratorHandler)
|
||||
r.Delete("/moderator/{secondaryuserID}", app.removeModeratorPriviligesHandler)
|
||||
|
||||
r.Get("/users", app.getGroupUsersHandler)
|
||||
r.Delete("/users/{secondaryuserID}", app.removeUserFromGroupHandler)
|
||||
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.Put("/owner", app.setGroupOwnerHandler)
|
||||
})
|
||||
})
|
||||
|
||||
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.Put("/confirm", app.confirmImageCreationHandler)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -140,7 +171,7 @@ func (app *application) mount() http.Handler {
|
||||
|
||||
})
|
||||
|
||||
r.Use(app.CSRFCheckMiddleware)
|
||||
// r.Use(app.CSRFCheckMiddleware)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(app.AuthSessionMiddleware)
|
||||
|
||||
@ -89,3 +89,5 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque
|
||||
func (app *application) refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
// func (app *application)
|
||||
|
||||
131
backend/cmd/api/groups.go
Normal file
131
backend/cmd/api/groups.go
Normal file
@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"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.cacheCfg.enabled {
|
||||
return app.store.Groups.GetUserGroups(ctx, userID)
|
||||
}
|
||||
|
||||
usergroups, err := app.cacheStorage.UserGroups.Get(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if usergroups == nil {
|
||||
usergroups, err = app.store.Groups.GetUserGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := app.cacheStorage.UserGroups.Set(ctx, usergroups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return usergroups, nil
|
||||
}
|
||||
|
||||
func (app *application) getGroup(ctx context.Context, groupID int64) (*storage.Group, error) {
|
||||
if !app.config.cacheCfg.enabled {
|
||||
return app.store.Groups.GetByID(ctx, groupID)
|
||||
|
||||
}
|
||||
|
||||
group, err := app.cacheStorage.Groups.Get(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if group == nil {
|
||||
group, err = app.store.Groups.GetByID(ctx, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := app.cacheStorage.Groups.Set(ctx, group); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (app *application) getUsersGroupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := app.getUserFromURL(w, r)
|
||||
if err != nil { // assume that the writing was already handled
|
||||
return
|
||||
}
|
||||
|
||||
usergroups, err := app.getUserGroups(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case storage.ErrNotFound:
|
||||
app.notFoundResponse(w, r, err)
|
||||
return
|
||||
default:
|
||||
app.internalServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.jsonResponse(w, http.StatusOK, usergroups); err != nil {
|
||||
app.internalServerError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) getUsersGroupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
groupID, err := strconv.ParseInt(chi.URLParam(r, "groupID"), 10, 64)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
group, err := app.getGroup(r.Context(), groupID)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case storage.ErrNotFound:
|
||||
app.notFoundResponse(w, r, err)
|
||||
return
|
||||
default:
|
||||
app.internalServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.jsonResponse(w, http.StatusOK, group); err != nil {
|
||||
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) {
|
||||
}
|
||||
@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// copied from https://github.com/sikozonpc/GopherSocial/blob/main/cmd/api/json.go
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, data any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
@ -18,3 +20,21 @@ func writeJSONError(w http.ResponseWriter, status int, message string) error {
|
||||
|
||||
return writeJSON(w, status, &envelope{Error: message})
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, data any) error {
|
||||
maxBytes := 1_048_578 // 1mb
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
return decoder.Decode(data)
|
||||
}
|
||||
|
||||
func (app *application) jsonResponse(w http.ResponseWriter, status int, data any) error {
|
||||
type envelope struct {
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
return writeJSON(w, status, &envelope{Data: data})
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,21 +4,40 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
l_context "git.ewellenr.ca/receipt_indexer/backend/internal/context"
|
||||
"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.getReceipts(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.getReceipt(r.Context(), getReceiptFromContext(r).ID)
|
||||
if err != nil {
|
||||
app.internalServerError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.jsonResponse(w, http.StatusOK, receipt); err != nil {
|
||||
app.internalServerError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) createReceiptHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@ -41,7 +60,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,7 +83,51 @@ 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)
|
||||
}
|
||||
|
||||
// encode the query into a string so that it's "indexable"
|
||||
queryID, err := storage.EncodeReceiptQuery(ctx, userID)
|
||||
|
||||
receiptIDs, err := app.cacheStorage.ReceiptList.Get(ctx, queryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var receipts []*storage.Receipt = nil
|
||||
|
||||
if receiptIDs == nil {
|
||||
receipts, err := app.store.Receipts.GetForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, val := range receipts {
|
||||
receiptIDs = append(receiptIDs, val.ID)
|
||||
}
|
||||
|
||||
if err := app.cacheStorage.ReceiptList.Set(ctx, cache.ReceiptListKeyVal{UserID: userID, QueryEncoding: queryID, List: receiptIDs}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
} else {
|
||||
for _, id := range receiptIDs {
|
||||
temp_receipt, err := app.getReceipt(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receipts = append(receipts, temp_receipt)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
func getReceiptFromContext(r *http.Request) *storage.Receipt {
|
||||
receipt, _ := r.Context().Value(receiptCtx).(*storage.Receipt)
|
||||
receipt, _ := r.Context().Value(l_context.ReceiptCtx).(*storage.Receipt)
|
||||
return receipt
|
||||
}
|
||||
|
||||
@ -3,16 +3,47 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type userKey string
|
||||
|
||||
const userCtx userKey = "user"
|
||||
|
||||
func (app *application) getUserFromURL(w http.ResponseWriter, r *http.Request) (*storage.User, error) {
|
||||
userID, err := strconv.ParseInt(chi.URLParam(r, "userID"), 10, 64)
|
||||
if err != nil {
|
||||
app.badRequestResponse(w, r, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := app.getUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case storage.ErrNotFound:
|
||||
app.notFoundResponse(w, r, err)
|
||||
return nil, err
|
||||
default:
|
||||
app.internalServerError(w, r, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (app *application) getUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, err := app.getUserFromURL(w, r)
|
||||
if err != nil { // assume that the writing was already handled
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.jsonResponse(w, http.StatusOK, user); err != nil {
|
||||
app.internalServerError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *application) getUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@ -20,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) getUser(ctx context.Context, userID int64) (*auth_storage.User, error) {
|
||||
if !app.config.redisCfg.enabled {
|
||||
return app.auth.Users.GetByID(ctx, userID)
|
||||
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.cacheCfg.enabled {
|
||||
return app.store.Users.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
user, err := app.cacheStorage.Users.Get(ctx, userID)
|
||||
@ -31,7 +65,7 @@ func (app *application) getUser(ctx context.Context, userID int64) (*auth_storag
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -44,7 +78,7 @@ func (app *application) getUser(ctx context.Context, userID int64) (*auth_storag
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func getUserFromContext(r *http.Request) *auth_storage.User {
|
||||
user, _ := r.Context().Value(userCtx).(*auth_storage.User)
|
||||
func getUserFromContext(r *http.Request) *storage.User {
|
||||
user, _ := r.Context().Value(userCtx).(*storage.User)
|
||||
return user
|
||||
}
|
||||
|
||||
23
backend/internal/context/context.go
Normal file
23
backend/internal/context/context.go
Normal file
@ -0,0 +1,23 @@
|
||||
package context
|
||||
|
||||
import "net/http"
|
||||
|
||||
type (
|
||||
pageKey string
|
||||
pagesizeKey string
|
||||
queryParamsKey string
|
||||
receiptKey string
|
||||
)
|
||||
|
||||
const (
|
||||
PageCtx pageKey = "page"
|
||||
PagesizeCtx pagesizeKey = "pageSize"
|
||||
QueryParamsCtx queryParamsKey = "queryParams"
|
||||
ReceiptCtx receiptKey = "receipt"
|
||||
)
|
||||
|
||||
func getPageAndSizeFromContext(r *http.Request) (int, int) {
|
||||
pageNumber, _ := r.Context().Value(PageCtx).(int)
|
||||
pageSize, _ := r.Context().Value(PagesizeCtx).(int)
|
||||
return pageNumber, pageSize
|
||||
}
|
||||
@ -36,9 +36,9 @@ Table roles {
|
||||
Table reciepts {
|
||||
id integer [primary key]
|
||||
groupid integer
|
||||
data nvarchar
|
||||
created_at timestamp
|
||||
updated_at timestamp
|
||||
data jsonb
|
||||
}
|
||||
|
||||
// Table imageOwnership {
|
||||
@ -52,7 +52,8 @@ Table images {
|
||||
receiptid integer
|
||||
created_at timestamp
|
||||
path varchar
|
||||
added bool
|
||||
expiration timestamp
|
||||
added bool [default: false]
|
||||
}
|
||||
|
||||
|
||||
@ -73,4 +74,4 @@ Ref: "roles"."id" < "users"."role"
|
||||
|
||||
Ref: "reciepts"."id" < "images"."receiptid"
|
||||
|
||||
Ref: "groups"."id" < "reciepts"."groupownerid"
|
||||
Ref: "groups"."id" < "reciepts"."groupid"
|
||||
@ -73,6 +73,7 @@ func (h *Hasher) HashStringWithSalt(in string, salt []byte) (string, error) {
|
||||
return h.Algo.EncodeHash(hash)
|
||||
}
|
||||
|
||||
// Generates a salt and hashes the given string with said salt
|
||||
func (h *Hasher) Hash(in string) ([]byte, error) {
|
||||
salt, err := GenerateRandomBytes(h.SaltLength)
|
||||
if err != nil {
|
||||
|
||||
31
backend/internal/storage/cache/cache.go
vendored
31
backend/internal/storage/cache/cache.go
vendored
@ -2,22 +2,53 @@ package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
// auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
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 {
|
||||
Users interface {
|
||||
Get(ctx context.Context, id int64) (*storage.User, error)
|
||||
Set(ctx context.Context, user *storage.User) error
|
||||
Delete(ctx context.Context, userID int64)
|
||||
}
|
||||
Roles interface {
|
||||
Get(ctx context.Context, name string) (*storage.Role, error)
|
||||
Set(ctx context.Context, role *storage.Role) error
|
||||
Delete(ctx context.Context, name string)
|
||||
}
|
||||
Groups interface {
|
||||
Get(ctx context.Context, id int64) (*storage.Group, error)
|
||||
Set(ctx context.Context, group *storage.Group) error
|
||||
Delete(ctx context.Context, id int64)
|
||||
}
|
||||
UserGroups interface {
|
||||
Get(ctx context.Context, userid int64) (*storage.UserGroups, error)
|
||||
Set(ctx context.Context, groups *storage.UserGroups) error
|
||||
Delete(ctx context.Context, userid int64)
|
||||
}
|
||||
Receipts interface {
|
||||
Get(ctx context.Context, id int64) (*storage.Receipt, error)
|
||||
Set(ctx context.Context, receipt *storage.Receipt) error
|
||||
Delete(ctx context.Context, id int64)
|
||||
}
|
||||
ReceiptList interface {
|
||||
Get(ctx context.Context, queryencoding string) ([]int64, error)
|
||||
Set(ctx context.Context, receiptidList ReceiptListKeyVal) error
|
||||
Delete(ctx context.Context, queryencoding string)
|
||||
}
|
||||
ReceiptImage interface {
|
||||
Get(ctx context.Context, id int64) (*storage.Image, error)
|
||||
Set(ctx context.Context, image *storage.Image) error
|
||||
|
||||
55
backend/internal/storage/cache/redis-group.go
vendored
Normal file
55
backend/internal/storage/cache/redis-group.go
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type GroupStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *GroupStore) generateCacheKey(id int64) string {
|
||||
return fmt.Sprintf("group-%d", id)
|
||||
}
|
||||
|
||||
func (s *GroupStore) Get(ctx context.Context, id int64) (*storage.Group, 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 group storage.Group
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *GroupStore) Set(ctx context.Context, group *storage.Group) error {
|
||||
cacheKey := s.generateCacheKey(group.ID)
|
||||
|
||||
json, err := json.Marshal(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.Set(ctx, cacheKey, json, RoleExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *GroupStore) Delete(ctx context.Context, id int64) {
|
||||
cacheKey := s.generateCacheKey(id)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
55
backend/internal/storage/cache/redis-image.go
vendored
Normal file
55
backend/internal/storage/cache/redis-image.go
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ReceiptImageStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *ReceiptImageStore) generateCacheKey(id int64) string {
|
||||
return fmt.Sprintf("receiptimage-%d", id)
|
||||
}
|
||||
|
||||
func (s *ReceiptImageStore) Get(ctx context.Context, id int64) (*storage.Image, 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 receiptimage storage.Image
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &receiptimage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &receiptimage, nil
|
||||
}
|
||||
|
||||
func (s *ReceiptImageStore) Set(ctx context.Context, receiptimage *storage.Image) error {
|
||||
cacheKey := s.generateCacheKey(receiptimage.ID)
|
||||
|
||||
json, err := json.Marshal(receiptimage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.Set(ctx, cacheKey, json, ReceiptImageExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *ReceiptImageStore) Delete(ctx context.Context, id int64) {
|
||||
cacheKey := s.generateCacheKey(id)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
55
backend/internal/storage/cache/redis-receipt.go
vendored
Normal file
55
backend/internal/storage/cache/redis-receipt.go
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ReceiptStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *ReceiptStore) generateCacheKey(id int64) string {
|
||||
return fmt.Sprintf("receipt-%d", id)
|
||||
}
|
||||
|
||||
func (s *ReceiptStore) Get(ctx context.Context, id int64) (*storage.Receipt, 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 receipt storage.Receipt
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &receipt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &receipt, nil
|
||||
}
|
||||
|
||||
func (s *ReceiptStore) Set(ctx context.Context, receipt *storage.Receipt) error {
|
||||
cacheKey := s.generateCacheKey(receipt.ID)
|
||||
|
||||
json, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.Set(ctx, cacheKey, json, ReceiptExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *ReceiptStore) Delete(ctx context.Context, id int64) {
|
||||
cacheKey := s.generateCacheKey(id)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
60
backend/internal/storage/cache/redis-receipts.go
vendored
Normal file
60
backend/internal/storage/cache/redis-receipts.go
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ReceiptListKeyVal struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
QueryEncoding string `json:"query_encoding"`
|
||||
List []int64 `json:"list"`
|
||||
}
|
||||
|
||||
type ReceiptListStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *ReceiptListStore) generateCacheKey(encoding string) string {
|
||||
return fmt.Sprintf("receiptList-%s", encoding)
|
||||
}
|
||||
|
||||
func (s *ReceiptListStore) Get(ctx context.Context, queryencoding string) ([]int64, error) {
|
||||
cacheKey := s.generateCacheKey(queryencoding)
|
||||
|
||||
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.QueryEncoding)
|
||||
|
||||
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, queryencoding string) {
|
||||
cacheKey := s.generateCacheKey(queryencoding)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
54
backend/internal/storage/cache/redis-role.go
vendored
Normal file
54
backend/internal/storage/cache/redis-role.go
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type RoleStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *RoleStore) generateCacheKey(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (s *RoleStore) Get(ctx context.Context, name string) (*storage.Role, error) {
|
||||
cacheKey := s.generateCacheKey(name)
|
||||
|
||||
data, err := s.rdb.Get(ctx, cacheKey).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var role storage.Role
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
func (s *RoleStore) Set(ctx context.Context, role *storage.Role) error {
|
||||
cacheKey := s.generateCacheKey(role.Name)
|
||||
|
||||
json, err := json.Marshal(role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.Set(ctx, cacheKey, json, RoleExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *RoleStore) Delete(ctx context.Context, name string) {
|
||||
cacheKey := s.generateCacheKey(name)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
// auth_storage "git.ewellenr.ca/receipt_indexer/backend/internal/storage/auth"
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@ -15,10 +13,12 @@ type UserStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
const UserExpTime = time.Minute
|
||||
func (s *UserStore) generateCacheKey(id int64) string {
|
||||
return fmt.Sprintf("user-%d", id)
|
||||
}
|
||||
|
||||
func (s *UserStore) Get(ctx context.Context, userID int64) (*storage.User, error) {
|
||||
cacheKey := fmt.Sprintf("user-%d", userID)
|
||||
cacheKey := s.generateCacheKey(userID)
|
||||
|
||||
data, err := s.rdb.Get(ctx, cacheKey).Result()
|
||||
if err == redis.Nil {
|
||||
@ -39,7 +39,7 @@ func (s *UserStore) Get(ctx context.Context, userID int64) (*storage.User, error
|
||||
}
|
||||
|
||||
func (s *UserStore) Set(ctx context.Context, user *storage.User) error {
|
||||
cacheKey := fmt.Sprintf("user-%d", user.ID)
|
||||
cacheKey := s.generateCacheKey(user.ID)
|
||||
|
||||
json, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
@ -50,6 +50,6 @@ func (s *UserStore) Set(ctx context.Context, user *storage.User) error {
|
||||
}
|
||||
|
||||
func (s *UserStore) Delete(ctx context.Context, userID int64) {
|
||||
cacheKey := fmt.Sprintf("user-%d", userID)
|
||||
cacheKey := s.generateCacheKey(userID)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
55
backend/internal/storage/cache/redis-usergroups.go
vendored
Normal file
55
backend/internal/storage/cache/redis-usergroups.go
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.ewellenr.ca/receipt_indexer/backend/internal/storage"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type UserGroupsStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
func (s *UserGroupsStore) generateCacheKey(id int64) string {
|
||||
return fmt.Sprintf("user-%d", id)
|
||||
}
|
||||
|
||||
func (s *UserGroupsStore) Get(ctx context.Context, userid int64) (*storage.UserGroups, error) {
|
||||
cacheKey := s.generateCacheKey(userid)
|
||||
|
||||
data, err := s.rdb.Get(ctx, cacheKey).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var usergroups storage.UserGroups
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &usergroups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &usergroups, nil
|
||||
}
|
||||
|
||||
func (s *UserGroupsStore) Set(ctx context.Context, groups *storage.UserGroups) error {
|
||||
cacheKey := s.generateCacheKey(groups.UserID)
|
||||
|
||||
json, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.Set(ctx, cacheKey, json, RoleExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *UserGroupsStore) Delete(ctx context.Context, userid int64) {
|
||||
cacheKey := s.generateCacheKey(userid)
|
||||
s.rdb.Del(ctx, cacheKey)
|
||||
}
|
||||
@ -7,3 +7,8 @@ type Group struct {
|
||||
Owner int64 `json:"owner"`
|
||||
Moderators []int64 `json:"moderators"`
|
||||
}
|
||||
|
||||
type UserGroups struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
}
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
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"`
|
||||
// Owner string `json:"username"`
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"user_id"`
|
||||
ImageIDs []int64 `json:"image_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Data ReceiptData `json:"receipt_data"`
|
||||
CreatedAt time.Time `json:"created_at`
|
||||
UpdatedAt time.Time `json:"updated_at`
|
||||
}
|
||||
|
||||
type ReceiptData struct {
|
||||
@ -20,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
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ type SQLGroupsStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *SQLGroupsStore) GetByID(ctx context.Context, id int64) (*Group, error) {
|
||||
func (s *SQLGroupsStore) getByID(ctx context.Context, id int64) (*Group, error) {
|
||||
query := `SELECT id, name, owner FROM groups WHERE id = $1`
|
||||
|
||||
group := &Group{}
|
||||
@ -18,20 +18,100 @@ func (s *SQLGroupsStore) GetByID(ctx context.Context, id int64) (*Group, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query = `SELECT userid, moderator FROM groupMembership WHERE groupid = $1`
|
||||
rows, err := s.db.QueryContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var userid int64
|
||||
var moderator bool
|
||||
err := rows.Scan(&userid, &moderator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if moderator {
|
||||
group.Moderators = append(group.Moderators, userid)
|
||||
} else {
|
||||
group.Users = append(group.Users, userid)
|
||||
}
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func (s *SQLGroupsStore) GetUserGroups(ctx context.Context, userID int64) ([]*Group, error) {
|
||||
// Implement logic to retrieve user's groups from the database
|
||||
}
|
||||
func (s *SQLGroupsStore) GetUsersInGroup(ctx context.Context, groupId int64) ([]*User, error) {
|
||||
// Implement logic to retrieve users in a group from the database
|
||||
func (s *SQLGroupsStore) GetByID(ctx context.Context, id int64) (*Group, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
return s.getByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *SQLGroupsStore) GetUserGroups(ctx context.Context, userID int64) (*UserGroups, error) {
|
||||
// Implement logic to retrieve user's groups from the database
|
||||
usergroups := &UserGroups{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
query := `SELECT groupid from groupMembership WHERE userid = $1`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var groupid int64
|
||||
err := rows.Scan(&groupid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usergroups.GroupIDs = append(usergroups.GroupIDs, groupid)
|
||||
}
|
||||
return usergroups, nil
|
||||
}
|
||||
|
||||
// func (s *SQLGroupsStore) GetUsersInGroup(ctx context.Context, groupId int64) ([]*User, error) {
|
||||
// // Implement logic to retrieve users in a group from the database
|
||||
// }
|
||||
|
||||
func (s *SQLGroupsStore) Create(ctx context.Context, group *Group) error {
|
||||
// Implement logic to create a new group in the database
|
||||
query := `INSERT INTO groups (name, owner) VALUES ($1, $2) RETURNING id`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, group.Name, group.Owner).Scan(&group.ID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLGroupsStore) Delete(ctx context.Context, id int64) error {
|
||||
// Implement logic to delete a group from the database
|
||||
query := `DELETE FROM groups WHERE id = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
res, err := s.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -3,17 +3,26 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SQLImagesStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s SQLImagesStore) GetByID(ctx context.Context, id int64) (*Image, error) {
|
||||
query := `SELECT id, receiptid, created_at, path FROM images WHERE id = $1`
|
||||
const imgStoreCleanupTime = time.Hour * 12
|
||||
|
||||
func NewSQLImageStore(db *sql.DB) *SQLImagesStore {
|
||||
imgstore := &SQLImagesStore{db}
|
||||
go imgstore.cleanupDeadImages(context.Background(), imgStoreCleanupTime)
|
||||
return imgstore
|
||||
}
|
||||
|
||||
func (s *SQLImagesStore) GetByID(ctx context.Context, id int64) (*Image, error) {
|
||||
query := `SELECT id, receiptid, created_at, path FROM images WHERE id = $1 AND added = $2`
|
||||
|
||||
image := &Image{}
|
||||
err := s.db.QueryRowContext(ctx, query, id).Scan(&image.ID, &image.ReceiptID, &image.CreatedAt, &image.Path)
|
||||
err := s.db.QueryRowContext(ctx, query, id, true).Scan(&image.ID, &image.ReceiptID, &image.CreatedAt, &image.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -21,10 +30,10 @@ func (s SQLImagesStore) GetByID(ctx context.Context, id int64) (*Image, error) {
|
||||
return image, nil
|
||||
}
|
||||
|
||||
func (s SQLImagesStore) Create(ctx context.Context, img *Image) error {
|
||||
func (s *SQLImagesStore) Create(ctx context.Context, img *Image, exp time.Duration) error {
|
||||
query := `
|
||||
INSERT INTO images (receiptid, path)
|
||||
VALUES ($1, $2) RETURNING id, created_at`
|
||||
INSERT INTO images (receiptid, path, expiration)
|
||||
VALUES ($1, $2, $3) RETURNING id, created_at`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
@ -34,6 +43,7 @@ func (s SQLImagesStore) Create(ctx context.Context, img *Image) error {
|
||||
query,
|
||||
img.ReceiptID,
|
||||
img.Path, // might need to marshal or serialize this
|
||||
time.Now().Add(exp),
|
||||
).Scan(
|
||||
&img.ID,
|
||||
&img.CreatedAt,
|
||||
@ -41,7 +51,7 @@ func (s SQLImagesStore) Create(ctx context.Context, img *Image) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s SQLImagesStore) Delete(ctx context.Context, id int64) error {
|
||||
func (s *SQLImagesStore) Delete(ctx context.Context, id int64) error {
|
||||
query := `DELETE FROM images WHERE id = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
@ -64,7 +74,7 @@ func (s SQLImagesStore) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SQLImagesStore) ActivateImage(ctx context.Context, id int64) error {
|
||||
func (s *SQLImagesStore) ActivateImage(ctx context.Context, id int64) error {
|
||||
query := `UPDATE images SET added = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
@ -77,3 +87,15 @@ func (s SQLImagesStore) ActivateImage(ctx context.Context, id int64) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLImagesStore) cleanupDeadImages(ctx context.Context, period time.Duration) {
|
||||
for range time.Tick(period) {
|
||||
query := `DELETE FROM images WHERE added = false AND expiration < $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
s.db.ExecContext(ctx, query, time.Now())
|
||||
}
|
||||
// ignore any errors, just keep looping. Not much lost in a missed cleanup. A little extra temporary storage. Big woop. If it's an issue, decrease the cleanup period
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ func (s *SQLRolesStore) GetByName(ctx context.Context, name string) (*Role, erro
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (s *SQLRolesStore) GetById(ctx context.Context, id int64) (*Role, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
@ -29,7 +29,7 @@ func (s *SQLUsersStore) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.Password.hash,
|
||||
&user.Password,
|
||||
&user.CreatedAt,
|
||||
&user.Role.ID,
|
||||
&user.Role.Name,
|
||||
@ -66,7 +66,7 @@ func (s *SQLUsersStore) GetByEmail(ctx context.Context, email string) (*User, er
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.Password.hash,
|
||||
&user.Password,
|
||||
&user.CreatedAt,
|
||||
&user.Role.ID,
|
||||
&user.Role.Name,
|
||||
@ -103,7 +103,7 @@ func (s *SQLUsersStore) GetByUsername(ctx context.Context, username string) (*Us
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.Password.hash,
|
||||
&user.Password,
|
||||
&user.CreatedAt,
|
||||
&user.Role.ID,
|
||||
&user.Role.Name,
|
||||
@ -181,12 +181,17 @@ func (s *SQLUsersStore) Create(ctx context.Context, user *User) error { // creat
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) CreateAndInvite(ctx context.Context, user *User, token string, exp time.Duration) error { // figure this out
|
||||
func (s *SQLUsersStore) CreateAndInvite(ctx context.Context, user *User, token string, exp time.Duration) error {
|
||||
// Implement the logic to create a user and invite them using the provided token with an expiration date.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) Activate(context.Context, string) error { // what does this do?
|
||||
return nil
|
||||
func (s *SQLUsersStore) Invite(ctx context.Context, exp time.Duration) (int64, string, error) {
|
||||
return -1, "", nil
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) CreateFromInvite(ctx context.Context, user *User) error {
|
||||
// Implement the logic to create a user from an invite, possibly accepting terms of service or other conditions.
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) delete(ctx context.Context, id int64, tx *sql.Tx) error {
|
||||
@ -195,7 +200,7 @@ func (s *SQLUsersStore) delete(ctx context.Context, id int64, tx *sql.Tx) error
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeoutDuration)
|
||||
defer cancel()
|
||||
|
||||
res, err := s.db.ExecContext(ctx, query, id)
|
||||
res, err := tx.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -218,14 +223,13 @@ func (s *SQLUsersStore) Delete(ctx context.Context, id int64) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) UpdateUserPass(ctx context.Context, user string, oldPassword string, newPass string) error {
|
||||
return nil
|
||||
}
|
||||
// func (s *SQLUsersStore) UpdateUserPass(ctx context.Context, user string, oldPassword string, newPass string) error {
|
||||
// // Implement the logic to update a user's password if the old password matches and is correct.
|
||||
// return nil
|
||||
// }
|
||||
|
||||
func (s *SQLUsersStore) CheckPass(ctx context.Context, name string, pass string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *SQLUsersStore) SigninUser(ctx context.Context, name string, pass string) (bool, *User, error) {
|
||||
// CheckPass(ctx context.Context, name string, pass string) (bool, error)
|
||||
func (s *SQLUsersStore) VerifyUserCredentials(ctx context.Context, username string, pass string) (bool, *User, error) {
|
||||
// Implement the logic to verify user credentials and return a boolean indicating success or failure along with the User details if successful.
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
@ -19,16 +21,24 @@ type Storage struct {
|
||||
GetByID(ctx context.Context, id int64) (*User, error)
|
||||
GetByEmail(context.Context, string) (*User, error)
|
||||
GetByUsername(context.Context, string) (*User, error)
|
||||
Create(context.Context, *User) error // create a non-exported create function which does take in the tx
|
||||
CreateAndInvite(ctx context.Context, user *User, token string, exp time.Duration) error // figure this out
|
||||
Activate(context.Context, string) error // what does this do?
|
||||
Create(context.Context, *User) error // create a non-exported create function which does take in the tx
|
||||
CreateAndInvite(ctx context.Context, user *User, token string, exp time.Duration) error
|
||||
Invite(ctx context.Context, exp time.Duration) (int64, string, error)
|
||||
CreateFromInvite(ctx context.Context, user *User) error
|
||||
Activate(context.Context, string) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
|
||||
UpdateUserPass(ctx context.Context, user string, oldPassword string, newPass string) error
|
||||
// UpdateUserPass(ctx context.Context, user string, oldPassword string, newPass string) error
|
||||
// Look into resetting user passwords later
|
||||
|
||||
// CheckPass(ctx context.Context, name string, pass string) (bool, error)
|
||||
// SigninUser(ctx context.Context, name string, pass string) (bool, *User, error)
|
||||
VerifyUserCredentials(ctx context.Context, username string, pass string) (bool, *User, error)
|
||||
// ValidCredentials(ctx context.Context, user *User, pass string) (bool, error)
|
||||
|
||||
// Figure out how to do user invites (so user account isn't truly made, no email, username, or password) and also how to do account creation with activation
|
||||
|
||||
// have createandinvite to do the activation stuff. so createandinvite -> activate does that process
|
||||
// use createandinvite to create a blank user have a createFromInvite which takes a *User and adds everything but the id to the database. this should then send an activation link and do the activation. so a two stepper
|
||||
}
|
||||
|
||||
Sessions interface { // store just session tokens, and their corresponding user id
|
||||
@ -54,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)
|
||||
@ -66,8 +77,8 @@ type Storage struct {
|
||||
}
|
||||
Groups interface {
|
||||
GetByID(context.Context, int64) (*Group, error)
|
||||
GetUserGroups(context.Context, int64) ([]*Group, error)
|
||||
GetUsersInGroup(context.Context, int64) ([]*User, error)
|
||||
GetUserGroups(context.Context, int64) (*UserGroups, error)
|
||||
// GetUsersInGroup(context.Context, int64) ([]*User, error)
|
||||
Create(context.Context, *Group) error
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
@ -75,6 +86,6 @@ type Storage struct {
|
||||
|
||||
func NewSQLRedisMinIOStorage(db *sql.DB) Storage {
|
||||
return Storage{
|
||||
Users: &SQLUsersStore{db},
|
||||
// Users: &SQLUsersStore{db},
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user