Merge pull request #380 from Receipt-Wrangler/feature/custom-fields

Feature/custom fields
This commit is contained in:
Noah Hall
2025-04-01 07:51:06 -04:00
committed by GitHub
22 changed files with 1914 additions and 28 deletions
+1 -1
View File
@@ -2,5 +2,5 @@ export DB_FILENAME=wrangler.db
export DB_ENGINE=sqlite
export ENCRYPTION_KEY=test
export SECRET_KEY=test
export REDIS_HOST=172.17.0.3
export REDIS_HOST=172.17.0.2
export REDIS_PORT=6379
@@ -0,0 +1,54 @@
package commands
import (
"encoding/json"
"net/http"
"receipt-wrangler/api/internal/models"
"receipt-wrangler/api/internal/structs"
"receipt-wrangler/api/internal/utils"
)
type UpsertCustomFieldCommand struct {
Name string `json:"name"`
Type models.CustomFieldType `json:"type"`
Description string `json:"description"`
Options []UpsertCustomFieldOptionCommand `json:"options"`
}
func (command *UpsertCustomFieldCommand) LoadDataFromRequest(w http.ResponseWriter, r *http.Request) error {
bytes, err := utils.GetBodyData(w, r)
if err != nil {
return err
}
err = json.Unmarshal(bytes, &command)
if err != nil {
return err
}
if command.Type != models.SELECT && len(command.Options) > 0 {
command.Options = []UpsertCustomFieldOptionCommand{}
}
return nil
}
func (command *UpsertCustomFieldCommand) Validate() structs.ValidatorError {
errors := make(map[string]string)
vErr := structs.ValidatorError{}
if len(command.Name) == 0 {
errors["name"] = "Name is required"
}
if len(command.Type) == 0 {
errors["type"] = "Type is required"
}
if command.Type == models.SELECT && len(command.Options) == 0 {
errors["options"] = "Options are required"
}
vErr.Errors = errors
return vErr
}
@@ -0,0 +1,6 @@
package commands
type UpsertCustomFieldOptionCommand struct {
Value string `json:"value"`
CustomFieldId uint `json:"custom_field_id"`
}
@@ -0,0 +1,16 @@
package commands
import (
"github.com/shopspring/decimal"
"time"
)
type UpsertCustomFieldValueCommand struct {
ReceiptId uint `json:"receiptId"`
CustomFieldId uint `json:"customFieldId"`
StringValue *string `json:"stringValue"`
DateValue *time.Time `json:"dateValue"`
SelectValue *uint `json:"selectValue"`
CurrencyValue *decimal.Decimal `json:"currencyValue"`
BooleanValue *bool `json:"booleanValue"`
}
+12 -11
View File
@@ -12,17 +12,18 @@ import (
)
type UpsertReceiptCommand struct {
Name string `json:"name"`
Amount decimal.Decimal `json:"amount"`
Date time.Time `json:"date"`
GroupId uint `json:"groupId"`
PaidByUserID uint `json:"paidByUserId"`
Status models.ReceiptStatus `json:"status"`
Categories []UpsertCategoryCommand `json:"categories"`
Tags []UpsertTagCommand `json:"tags"`
Items []UpsertItemCommand `json:"receiptItems"`
Comments []UpsertCommentCommand `json:"comments"`
CreatedByString string `json:"createdByString"`
Name string `json:"name"`
Amount decimal.Decimal `json:"amount"`
Date time.Time `json:"date"`
GroupId uint `json:"groupId"`
PaidByUserID uint `json:"paidByUserId"`
Status models.ReceiptStatus `json:"status"`
Categories []UpsertCategoryCommand `json:"categories"`
Tags []UpsertTagCommand `json:"tags"`
Items []UpsertItemCommand `json:"receiptItems"`
Comments []UpsertCommentCommand `json:"comments"`
CustomFields []UpsertCustomFieldValueCommand `json:"customFields"`
CreatedByString string `json:"createdByString"`
}
func (receipt *UpsertReceiptCommand) LoadDataFromRequest(w http.ResponseWriter, r *http.Request) error {
+3
View File
@@ -18,6 +18,7 @@ func GetAllCategories(w http.ResponseWriter, r *http.Request) {
ErrorMessage: "Error retrieving categories",
Writer: w,
Request: r,
UserRole: models.USER,
ResponseType: constants.ApplicationJson,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
categoriesRepository := repositories.NewCategoryRepository(nil)
@@ -46,6 +47,7 @@ func CreateCategory(w http.ResponseWriter, r *http.Request) {
ErrorMessage: "Error creating category",
Writer: w,
Request: r,
UserRole: models.USER,
ResponseType: constants.ApplicationJson,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
category := models.Category{}
@@ -81,6 +83,7 @@ func GetPagedCategories(w http.ResponseWriter, r *http.Request) {
Writer: w,
Request: r,
ResponseType: constants.ApplicationJson,
UserRole: models.USER,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
pagedData := structs.PagedData{}
pagedRequestCommand := commands.PagedRequestCommand{}
+6
View File
@@ -33,6 +33,9 @@ func TestShouldGetAllCategories(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/api", reader)
newContext := context.WithValue(r.Context(), jwtmiddleware.ContextKey{}, &validator.ValidatedClaims{CustomClaims: &structs.Claims{UserId: 2, UserRole: models.USER}})
r = r.WithContext(newContext)
GetAllCategories(w, r)
err := json.Unmarshal(w.Body.Bytes(), &categories)
@@ -64,6 +67,9 @@ func TestShouldCreateCategory(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/api", reader)
newContext := context.WithValue(r.Context(), jwtmiddleware.ContextKey{}, &validator.ValidatedClaims{CustomClaims: &structs.Claims{UserId: 2, UserRole: models.USER}})
r = r.WithContext(newContext)
CreateCategory(w, r)
err := json.Unmarshal(w.Body.Bytes(), &category)
+168
View File
@@ -0,0 +1,168 @@
package handlers
import (
"encoding/json"
"github.com/go-chi/chi/v5"
"net/http"
"receipt-wrangler/api/internal/commands"
"receipt-wrangler/api/internal/constants"
"receipt-wrangler/api/internal/models"
"receipt-wrangler/api/internal/repositories"
"receipt-wrangler/api/internal/structs"
"receipt-wrangler/api/internal/utils"
)
func GetPagedCustomFields(w http.ResponseWriter, r *http.Request) {
handler := structs.Handler{
ErrorMessage: "Error getting custom fields",
Writer: w,
Request: r,
ResponseType: constants.ApplicationJson,
UserRole: models.USER,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
pagedData := structs.PagedData{}
pagedRequestCommand := commands.PagedRequestCommand{}
err := pagedRequestCommand.LoadDataFromRequest(w, r)
if err != nil {
return http.StatusInternalServerError, err
}
vErrs := pagedRequestCommand.Validate()
if len(vErrs.Errors) > 0 {
structs.WriteValidatorErrorResponse(w, vErrs, http.StatusBadRequest)
return 0, nil
}
customFieldsRepository := repositories.NewCustomFieldRepository(nil)
customFields, count, err := customFieldsRepository.GetPagedCustomFields(pagedRequestCommand)
if err != nil {
return http.StatusInternalServerError, err
}
anyData := make([]any, len(customFields))
for i := 0; i < len(customFields); i++ {
anyData[i] = customFields[i]
}
pagedData.Data = anyData
pagedData.TotalCount = count
bytes, err := utils.MarshalResponseData(pagedData)
if err != nil {
return http.StatusInternalServerError, err
}
w.WriteHeader(http.StatusOK)
w.Write(bytes)
return 0, nil
},
}
HandleRequest(handler)
}
func CreateCustomField(w http.ResponseWriter, r *http.Request) {
handler := structs.Handler{
ErrorMessage: "Error creating custom field",
Writer: w,
Request: r,
ResponseType: constants.ApplicationJson,
UserRole: models.USER,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
command := commands.UpsertCustomFieldCommand{}
err := command.LoadDataFromRequest(w, r)
if err != nil {
return http.StatusInternalServerError, err
}
vErrs := command.Validate()
if len(vErrs.Errors) > 0 {
structs.WriteValidatorErrorResponse(w, vErrs, http.StatusBadRequest)
return 0, nil
}
token := structs.GetJWT(r)
customFieldsRepository := repositories.NewCustomFieldRepository(nil)
customField, err := customFieldsRepository.CreateCustomField(command, &token.UserId)
if err != nil {
return http.StatusInternalServerError, err
}
bytes, err := json.Marshal(customField)
if err != nil {
return http.StatusInternalServerError, err
}
w.WriteHeader(http.StatusOK)
w.Write(bytes)
return 0, nil
},
}
HandleRequest(handler)
}
func GetCustomFieldById(w http.ResponseWriter, r *http.Request) {
handler := structs.Handler{
ErrorMessage: "Error getting custom field",
Writer: w,
Request: r,
ResponseType: constants.ApplicationJson,
UserRole: models.USER,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
customFieldId := chi.URLParam(r, "id")
customFieldIdUint, err := utils.StringToUint(customFieldId)
if err != nil {
return http.StatusInternalServerError, err
}
customFieldsRepository := repositories.NewCustomFieldRepository(nil)
customField, err := customFieldsRepository.GetCustomFieldById(customFieldIdUint)
if err != nil {
return http.StatusInternalServerError, err
}
bytes, err := json.Marshal(customField)
if err != nil {
return http.StatusInternalServerError, err
}
w.WriteHeader(http.StatusOK)
w.Write(bytes)
return 0, nil
},
}
HandleRequest(handler)
}
func DeleteCustomField(w http.ResponseWriter, r *http.Request) {
handler := structs.Handler{
ErrorMessage: "Error deleting custom field",
Writer: w,
Request: r,
ResponseType: constants.ApplicationJson,
UserRole: models.ADMIN,
HandlerFunction: func(w http.ResponseWriter, r *http.Request) (int, error) {
customFieldId := chi.URLParam(r, "id")
customFieldIdUint, err := utils.StringToUint(customFieldId)
if err != nil {
return http.StatusInternalServerError, err
}
customFieldsRepository := repositories.NewCustomFieldRepository(nil)
err = customFieldsRepository.DeleteCustomField(customFieldIdUint)
if err != nil {
return http.StatusInternalServerError, err
}
w.WriteHeader(http.StatusOK)
return 0, nil
},
}
HandleRequest(handler)
}
+604
View File
@@ -0,0 +1,604 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"receipt-wrangler/api/internal/commands"
"receipt-wrangler/api/internal/models"
"receipt-wrangler/api/internal/repositories"
"receipt-wrangler/api/internal/structs"
"receipt-wrangler/api/internal/utils"
"strings"
"testing"
jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
"github.com/auth0/go-jwt-middleware/v2/validator"
"github.com/go-chi/chi/v5"
)
func setupCustomFieldHandlerTest() {
createTestCustomFields()
}
func createTestCustomFields() {
db := repositories.GetDB()
// Create custom field with TEXT type
textField := models.CustomField{
Name: "Test Text Field",
Type: models.TEXT,
Description: "A test text field",
}
db.Create(&textField)
// Create custom field with DATE type
dateField := models.CustomField{
Name: "Test Date Field",
Type: models.DATE,
Description: "A test date field",
}
db.Create(&dateField)
// Create custom field with SELECT type and options
selectField := models.CustomField{
Name: "Test Select Field",
Type: models.SELECT,
Description: "A test select field",
}
db.Create(&selectField)
// Add options to the SELECT field
option1 := models.CustomFieldOption{
Value: "Option 1",
CustomFieldId: selectField.ID,
}
option2 := models.CustomFieldOption{
Value: "Option 2",
CustomFieldId: selectField.ID,
}
db.Create(&option1)
db.Create(&option2)
// Create custom field with CURRENCY type
currencyField := models.CustomField{
Name: "Test Currency Field",
Type: models.CURRENCY,
Description: "A test currency field",
}
db.Create(&currencyField)
}
func teardownCustomFieldHandlerTest() {
repositories.TruncateTestDb()
}
func createCustomFieldHandlerTestRequest(method, url string, body string) (*http.Request, *httptest.ResponseRecorder) {
var bodyReader *strings.Reader
if body != "" {
bodyReader = strings.NewReader(body)
} else {
bodyReader = strings.NewReader("")
}
req := httptest.NewRequest(method, url, bodyReader)
req.Header.Set("Content-Type", "application/json")
// Add JWT context with mock user claims
var vClaims validator.ValidatedClaims
vClaims.CustomClaims = &structs.Claims{
UserId: 1,
Username: "testuser",
Displayname: "Test User",
UserRole: models.ADMIN,
}
// Set the JWT context
ctx := req.Context()
ctx = context.WithValue(ctx, jwtmiddleware.ContextKey{}, &vClaims)
req = req.WithContext(ctx)
return req, httptest.NewRecorder()
}
func TestGetPagedCustomFieldsHandler(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with paged request payload
pagedRequestCommand := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "name",
SortDirection: commands.ASCENDING,
}
pagedRequestJSON, _ := json.Marshal(pagedRequestCommand)
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
string(pagedRequestJSON),
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Parse response
var response structs.PagedData
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Should return 4 custom fields
if response.TotalCount != 4 {
utils.PrintTestError(t, response.TotalCount, 4)
}
if len(response.Data) != 4 {
utils.PrintTestError(t, len(response.Data), 4)
}
// Convert data to CustomField array
customFieldsBytes, _ := json.Marshal(response.Data)
var customFields []models.CustomField
json.Unmarshal(customFieldsBytes, &customFields)
// Check if fields are correctly ordered by name
if customFields[0].Name != "Test Currency Field" {
utils.PrintTestError(t, customFields[0].Name, "Test Currency Field")
}
if customFields[1].Name != "Test Date Field" {
utils.PrintTestError(t, customFields[1].Name, "Test Date Field")
}
if customFields[2].Name != "Test Select Field" {
utils.PrintTestError(t, customFields[2].Name, "Test Select Field")
}
if customFields[3].Name != "Test Text Field" {
utils.PrintTestError(t, customFields[3].Name, "Test Text Field")
}
}
func TestGetPagedCustomFieldsHandlerWithPagination(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with paged request payload
pagedRequestCommand := commands.PagedRequestCommand{
Page: 1,
PageSize: 2,
OrderBy: "name",
SortDirection: commands.ASCENDING,
}
pagedRequestJSON, _ := json.Marshal(pagedRequestCommand)
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
string(pagedRequestJSON),
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Parse response
var response structs.PagedData
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Total count should still be 4
if response.TotalCount != 4 {
utils.PrintTestError(t, response.TotalCount, 4)
}
// But we should only get 2 items per page
if len(response.Data) != 2 {
utils.PrintTestError(t, len(response.Data), 2)
}
// Test second page
pagedRequestCommand.Page = 2
pagedRequestJSON, _ = json.Marshal(pagedRequestCommand)
req, rr = createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
string(pagedRequestJSON),
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Parse response
err = json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Total count should still be 4
if response.TotalCount != 4 {
utils.PrintTestError(t, response.TotalCount, 4)
}
// And we should get the other 2 items
if len(response.Data) != 2 {
utils.PrintTestError(t, len(response.Data), 2)
}
}
func TestGetPagedCustomFieldsHandlerWithTypeOrdering(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with paged request payload
pagedRequestCommand := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "type",
SortDirection: commands.ASCENDING,
}
pagedRequestJSON, _ := json.Marshal(pagedRequestCommand)
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
string(pagedRequestJSON),
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Parse response
var response structs.PagedData
err := json.Unmarshal(rr.Body.Bytes(), &response)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Convert data to CustomField array
customFieldsBytes, _ := json.Marshal(response.Data)
var customFields []models.CustomField
json.Unmarshal(customFieldsBytes, &customFields)
// Check ordering by type
if customFields[0].Type != models.CURRENCY {
utils.PrintTestError(t, string(customFields[0].Type), string(models.CURRENCY))
}
}
func TestGetPagedCustomFieldsHandlerWithInvalidOrderBy(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with invalid orderBy
pagedRequestCommand := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "invalid_field",
SortDirection: commands.ASCENDING,
}
pagedRequestJSON, _ := json.Marshal(pagedRequestCommand)
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
string(pagedRequestJSON),
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
// Parse error response
var errorResponse map[string]string
err := json.Unmarshal(rr.Body.Bytes(), &errorResponse)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Check error message
if errorMsg, exists := errorResponse["errorMsg"]; !exists || !strings.Contains(errorMsg, "Error getting custom fields") {
utils.PrintTestError(t, errorMsg, "Error getting custom fields")
}
}
func TestGetPagedCustomFieldsHandlerWithInvalidBody(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with invalid JSON body
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
"{invalid json",
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
}
func TestGetPagedCustomFieldsHandlerWithEmptyBody(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with empty body
req, rr := createCustomFieldHandlerTestRequest(
"POST",
"/api/customField/getPagedCustomFields",
"",
)
// Call the handler
GetPagedCustomFields(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
}
func TestGetCustomFieldByIdHandler(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Get a custom field from the test DB to use its ID
db := repositories.GetDB()
var customField models.CustomField
db.Where("name = ?", "Test Text Field").First(&customField)
// Create request to get custom field by ID
req, rr := createCustomFieldHandlerTestRequest(
"GET",
"/api/customField/"+utils.UintToString(customField.ID),
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", utils.UintToString(customField.ID))
// Call the handler
GetCustomFieldById(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Parse response
var responseCustomField models.CustomField
err := json.Unmarshal(rr.Body.Bytes(), &responseCustomField)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Check if the returned custom field has the correct ID and properties
if responseCustomField.ID != customField.ID {
utils.PrintTestError(t, responseCustomField.ID, customField.ID)
}
if responseCustomField.Name != "Test Text Field" {
utils.PrintTestError(t, responseCustomField.Name, "Test Text Field")
}
if responseCustomField.Type != models.TEXT {
utils.PrintTestError(t, string(responseCustomField.Type), string(models.TEXT))
}
}
func TestGetCustomFieldByIdHandlerWithInvalidId(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with invalid ID
req, rr := createCustomFieldHandlerTestRequest(
"GET",
"/api/customField/invalid",
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", "invalid")
// Call the handler
GetCustomFieldById(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
// Parse error response
var errorResponse map[string]string
err := json.Unmarshal(rr.Body.Bytes(), &errorResponse)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Check error message
if errorMsg, exists := errorResponse["errorMsg"]; !exists || !strings.Contains(errorMsg, "Error getting custom field") {
utils.PrintTestError(t, errorMsg, "Error getting custom field")
}
}
func TestGetCustomFieldByIdHandlerWithNonExistentId(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Use a non-existent ID (999)
nonExistentId := "999"
// Create request with non-existent ID
req, rr := createCustomFieldHandlerTestRequest(
"GET",
"/api/customField/"+nonExistentId,
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", nonExistentId)
// Call the handler
GetCustomFieldById(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
// Parse error response
var errorResponse map[string]string
err := json.Unmarshal(rr.Body.Bytes(), &errorResponse)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Check error message
if errorMsg, exists := errorResponse["errorMsg"]; !exists || !strings.Contains(errorMsg, "Error getting custom field") {
utils.PrintTestError(t, errorMsg, "Error getting custom field")
}
}
func TestDeleteCustomFieldHandler(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
db := repositories.GetDB()
// Get a custom field from the test DB to use its ID
var customField models.CustomField
db.Where("name = ?", "Test Text Field").First(&customField)
customFieldId := customField.ID
// Create request to delete custom field
req, rr := createCustomFieldHandlerTestRequest(
"DELETE",
"/api/customField/"+utils.UintToString(customFieldId),
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", utils.UintToString(customFieldId))
// Call the handler
DeleteCustomField(rr, req)
// Check response status
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
// Verify the custom field was deleted
var count int64
db.Model(&models.CustomField{}).Where("id = ?", customFieldId).Count(&count)
if count != 0 {
utils.PrintTestError(t, count, 0)
}
}
func TestDeleteCustomFieldHandlerWithInvalidId(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Create request with invalid ID
req, rr := createCustomFieldHandlerTestRequest(
"DELETE",
"/api/customField/invalid",
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", "invalid")
// Call the handler
DeleteCustomField(rr, req)
// Check response status - should be error
if status := rr.Code; status != http.StatusInternalServerError {
utils.PrintTestError(t, status, http.StatusInternalServerError)
}
// Parse error response
var errorResponse map[string]string
err := json.Unmarshal(rr.Body.Bytes(), &errorResponse)
if err != nil {
utils.PrintTestError(t, err.Error(), nil)
}
// Check error message
if errorMsg, exists := errorResponse["errorMsg"]; !exists || !strings.Contains(errorMsg, "Error deleting custom field") {
utils.PrintTestError(t, errorMsg, "Error deleting custom field")
}
}
func TestDeleteCustomFieldHandlerWithNonExistentId(t *testing.T) {
defer teardownCustomFieldHandlerTest()
setupCustomFieldHandlerTest()
// Use a non-existent ID (999)
nonExistentId := "999"
// Create request with non-existent ID
req, rr := createCustomFieldHandlerTestRequest(
"DELETE",
"/api/customField/"+nonExistentId,
"",
)
// Set URL parameter
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
chiCtx := chi.RouteContext(req.Context())
chiCtx.URLParams.Add("id", nonExistentId)
// Call the handler
DeleteCustomField(rr, req)
// Should still return OK because deleting a non-existent record is not an error in this implementation
if status := rr.Code; status != http.StatusOK {
utils.PrintTestError(t, status, http.StatusOK)
}
}
+9
View File
@@ -0,0 +1,9 @@
package models
type CustomField struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Type CustomFieldType `gorm:"not null" json:"type"`
Description string `json:"description"`
Options []CustomFieldOption `json:"options"`
}
+8
View File
@@ -0,0 +1,8 @@
package models
type CustomFieldOption struct {
BaseModel
Value string `gorm:"not null" json:"value"`
CustomField CustomField `json:"-"`
CustomFieldId uint `gorm:"not null" json:"customFieldId"`
}
+36
View File
@@ -0,0 +1,36 @@
package models
import (
"database/sql/driver"
"errors"
)
type CustomFieldType string
const (
TEXT CustomFieldType = "TEXT"
DATE CustomFieldType = "DATE"
SELECT CustomFieldType = "SELECT"
CURRENCY CustomFieldType = "CURRENCY"
BOOLEAN CustomFieldType = "BOOLEAN"
)
func (fieldType *CustomFieldType) Scan(value string) error {
*fieldType = CustomFieldType(value)
return nil
}
func (fieldType CustomFieldType) Value() (driver.Value, error) {
if len(fieldType) == 0 {
return "", nil
}
if fieldType != TEXT &&
fieldType != DATE &&
fieldType != SELECT &&
fieldType != CURRENCY &&
fieldType != BOOLEAN {
return "", errors.New("invalid custom field type")
}
return string(fieldType), nil
}
+19
View File
@@ -0,0 +1,19 @@
package models
import (
"github.com/shopspring/decimal"
"time"
)
type CustomFieldValue struct {
BaseModel
Receipt Receipt `json:"-"`
ReceiptId uint `json:"receiptId"`
CustomField CustomField `json:"-"`
CustomFieldId uint `json:"customFieldId"`
StringValue *string `json:"stringValue"`
DateValue *time.Time `json:"dateValue"`
SelectValue *uint `json:"selectValue"`
CurrencyValue *decimal.Decimal `json:"currencyValue"`
BooleanValue *bool `json:"booleanValue"`
}
+15 -14
View File
@@ -8,20 +8,21 @@ import (
type Receipt struct {
BaseModel
Name string `gorm:"not null" json:"name"`
Amount decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"amount"`
Date time.Time `gorm:"not null" json:"date"`
ResolvedDate *time.Time `json:"resolvedDate"`
PaidByUserID uint `json:"paidByUserId"`
PaidByUser User `json:"-"`
Status ReceiptStatus `gorm:"default:'OPEN';not null" json:"status"`
GroupId uint `gorm:"not null" json:"groupId"`
Group Group `json:"-"`
Categories []Category `gorm:"many2many:receipt_categories" json:"categories"`
Tags []Tag `gorm:"many2many:receipt_tags" json:"tags"`
ImageFiles []FileData `json:"imageFiles"`
ReceiptItems []Item `json:"receiptItems"`
Comments []Comment `json:"comments"`
Name string `gorm:"not null" json:"name"`
Amount decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"amount"`
Date time.Time `gorm:"not null" json:"date"`
ResolvedDate *time.Time `json:"resolvedDate"`
PaidByUserID uint `json:"paidByUserId"`
PaidByUser User `json:"-"`
Status ReceiptStatus `gorm:"default:'OPEN';not null" json:"status"`
GroupId uint `gorm:"not null" json:"groupId"`
Group Group `json:"-"`
Categories []Category `gorm:"many2many:receipt_categories" json:"categories"`
Tags []Tag `gorm:"many2many:receipt_tags" json:"tags"`
ImageFiles []FileData `json:"imageFiles"`
ReceiptItems []Item `json:"receiptItems"`
Comments []Comment `json:"comments"`
CustomFields []CustomFieldValue `json:"customFields"`
}
func (r *Receipt) ToString() (string, error) {
+10 -2
View File
@@ -8,6 +8,14 @@ func BuildGroupMap() map[GroupRole]int {
return groupMap
}
func HasRole(role UserRole, roleToCheck UserRole) bool {
return role == roleToCheck
func BuildUserRoleMap() map[UserRole]int {
userRoleMap := make(map[UserRole]int)
userRoleMap[USER] = 0
userRoleMap[ADMIN] = 1
return userRoleMap
}
func HasRole(role UserRole, roleToCheck UserRole) bool {
userRoleMap := BuildUserRoleMap()
return userRoleMap[role] <= userRoleMap[roleToCheck]
}
+129
View File
@@ -0,0 +1,129 @@
package repositories
import (
"errors"
"gorm.io/gorm"
"receipt-wrangler/api/internal/commands"
"receipt-wrangler/api/internal/models"
)
type CustomFieldRepository struct {
BaseRepository
}
func NewCustomFieldRepository(tx *gorm.DB) CustomFieldRepository {
repository := CustomFieldRepository{BaseRepository: BaseRepository{
DB: GetDB(),
TX: tx,
}}
return repository
}
func (repository CustomFieldRepository) GetPagedCustomFields(
pagedRequestCommand commands.PagedRequestCommand,
) ([]models.CustomField, int64, error) {
db := repository.GetDB()
var customFields []models.CustomField
err := repository.validateOrderBy(pagedRequestCommand.OrderBy)
if err != nil {
return customFields, 0, err
}
query := repository.Sort(db, pagedRequestCommand.OrderBy, pagedRequestCommand.SortDirection)
query = query.Scopes(repository.Paginate(pagedRequestCommand.Page, pagedRequestCommand.PageSize))
err = query.Model(&models.CustomField{}).Preload("Options").Find(&customFields).Error
if err != nil {
return nil, 0, err
}
var count int64
err = db.Model(&models.CustomField{}).Count(&count).Error
if err != nil {
return nil, 0, err
}
return customFields, count, nil
}
func (repository CustomFieldRepository) CreateCustomField(
command commands.UpsertCustomFieldCommand,
createdBy *uint,
) (models.CustomField, error) {
db := repository.GetDB()
options := make([]models.CustomFieldOption, 0, len(command.Options))
for _, optionCommand := range command.Options {
option := models.CustomFieldOption{
CustomFieldId: optionCommand.CustomFieldId,
Value: optionCommand.Value,
}
options = append(options, option)
}
customFieldToCreate := models.CustomField{
BaseModel: models.BaseModel{
CreatedBy: createdBy,
},
Name: command.Name,
Type: command.Type,
Description: command.Description,
Options: options,
}
err := db.Create(&customFieldToCreate).Error
if err != nil {
return models.CustomField{}, err
}
return customFieldToCreate, nil
}
func (repository CustomFieldRepository) GetCustomFieldById(id uint) (models.CustomField, error) {
db := repository.GetDB()
var customField models.CustomField
err := db.Preload("Options").First(&customField, id).Error
if err != nil {
return models.CustomField{}, err
}
return customField, nil
}
func (repository CustomFieldRepository) DeleteCustomField(id uint) error {
db := repository.GetDB()
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Delete(&models.CustomFieldOption{}, "custom_field_id = ?", id).Error
if err != nil {
return err
}
err = tx.Delete(&models.CustomFieldValue{}, "custom_field_id = ?", id).Error
if err != nil {
return err
}
err = tx.Delete(&models.CustomField{}, id).Error
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
func (repository CustomFieldRepository) validateOrderBy(orderBy string) error {
if orderBy != "name" && orderBy != "type" && orderBy != "description" {
return errors.New("invalid orderBy")
}
return nil
}
+547
View File
@@ -0,0 +1,547 @@
package repositories
import (
"receipt-wrangler/api/internal/commands"
"receipt-wrangler/api/internal/models"
"receipt-wrangler/api/internal/utils"
"testing"
)
func createTestCustomFields() {
db := GetDB()
// Create custom field with TEXT type
textField := models.CustomField{
Name: "Test Text Field",
Type: models.TEXT,
Description: "A test text field",
}
db.Create(&textField)
// Create custom field with DATE type
dateField := models.CustomField{
Name: "Test Date Field",
Type: models.DATE,
Description: "A test date field",
}
db.Create(&dateField)
// Create custom field with SELECT type and options
selectField := models.CustomField{
Name: "Test Select Field",
Type: models.SELECT,
Description: "A test select field",
}
db.Create(&selectField)
// Add options to the SELECT field
option1 := models.CustomFieldOption{
Value: "Option 1",
CustomFieldId: selectField.ID,
}
option2 := models.CustomFieldOption{
Value: "Option 2",
CustomFieldId: selectField.ID,
}
db.Create(&option1)
db.Create(&option2)
// Create custom field with CURRENCY type
currencyField := models.CustomField{
Name: "Test Currency Field",
Type: models.CURRENCY,
Description: "A test currency field",
}
db.Create(&currencyField)
}
func setupCustomFieldRepositoryTest() {
createTestCustomFields()
}
func teardownCustomFieldRepositoryTest() {
TruncateTestDb()
}
func TestShouldCreateCustomField(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
createdBy := uint(1)
// Create a TEXT type custom field
command := commands.UpsertCustomFieldCommand{
Name: "Test New Text Field",
Type: models.TEXT,
Description: "A new test text field",
Options: []commands.UpsertCustomFieldOptionCommand{},
}
customField, err := repository.CreateCustomField(command, &createdBy)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Validate the created custom field
if customField.ID == 0 {
utils.PrintTestError(t, "Custom field ID should not be 0", nil)
}
if customField.Name != "Test New Text Field" {
utils.PrintTestError(t, customField.Name, "Test New Text Field")
}
if customField.Type != models.TEXT {
utils.PrintTestError(t, customField.Type, models.TEXT)
}
if customField.Description != "A new test text field" {
utils.PrintTestError(t, customField.Description, "A new test text field")
}
if *customField.CreatedBy != createdBy {
utils.PrintTestError(t, *customField.CreatedBy, createdBy)
}
}
func TestShouldCreateCustomFieldWithOptions(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
createdBy := uint(1)
// Create a SELECT type custom field with options
command := commands.UpsertCustomFieldCommand{
Name: "Test New Select Field",
Type: models.SELECT,
Description: "A new test select field",
Options: []commands.UpsertCustomFieldOptionCommand{
{
Value: "Option A",
},
{
Value: "Option B",
},
},
}
customField, err := repository.CreateCustomField(command, &createdBy)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Validate the created custom field
if customField.ID == 0 {
utils.PrintTestError(t, "Custom field ID should not be 0", nil)
}
if customField.Type != models.SELECT {
utils.PrintTestError(t, customField.Type, models.SELECT)
}
if len(customField.Options) != 2 {
utils.PrintTestError(t, len(customField.Options), 2)
return
}
if customField.Options[0].Value != "Option A" {
utils.PrintTestError(t, customField.Options[0].Value, "Option A")
}
if customField.Options[1].Value != "Option B" {
utils.PrintTestError(t, customField.Options[1].Value, "Option B")
}
}
func TestShouldGetPagedCustomFieldsWithDefaultSorting(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Create paged request with default settings
pagedRequest := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "name",
SortDirection: commands.ASCENDING,
}
customFields, count, err := repository.GetPagedCustomFields(pagedRequest)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Should return 4 custom fields
if count != 4 {
utils.PrintTestError(t, count, 4)
}
if len(customFields) != 4 {
utils.PrintTestError(t, len(customFields), 4)
}
// Check if fields are correctly ordered by name
if customFields[0].Name != "Test Currency Field" {
utils.PrintTestError(t, customFields[0].Name, "Test Currency Field")
}
if customFields[1].Name != "Test Date Field" {
utils.PrintTestError(t, customFields[1].Name, "Test Date Field")
}
}
func TestShouldGetPagedCustomFieldsWithLimit(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Create paged request with limit
pagedRequest := commands.PagedRequestCommand{
Page: 1,
PageSize: 2,
OrderBy: "name",
SortDirection: commands.ASCENDING,
}
customFields, count, err := repository.GetPagedCustomFields(pagedRequest)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Total count should be 4
if count != 4 {
utils.PrintTestError(t, count, 4)
}
// But only 2 items should be returned
if len(customFields) != 2 {
utils.PrintTestError(t, len(customFields), 2)
}
}
func TestShouldGetPagedCustomFieldsWithSecondPage(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Create paged request for second page
pagedRequest := commands.PagedRequestCommand{
Page: 2,
PageSize: 2,
OrderBy: "name",
SortDirection: commands.ASCENDING,
}
customFields, count, err := repository.GetPagedCustomFields(pagedRequest)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Total count should be 4
if count != 4 {
utils.PrintTestError(t, count, 4)
}
// 2 items on the second page
if len(customFields) != 2 {
utils.PrintTestError(t, len(customFields), 2)
}
// The items on the second page should be different from the first page
if customFields[0].Name != "Test Select Field" {
utils.PrintTestError(t, customFields[0].Name, "Test Select Field")
}
if customFields[1].Name != "Test Text Field" {
utils.PrintTestError(t, customFields[1].Name, "Test Text Field")
}
}
func TestShouldGetPagedCustomFieldsWithDescendingOrder(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Create paged request with descending order
pagedRequest := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "name",
SortDirection: commands.DESCENDING,
}
customFields, count, err := repository.GetPagedCustomFields(pagedRequest)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
if count != 4 {
utils.PrintTestError(t, count, 4)
}
// Check if fields are correctly ordered by name in descending order
if customFields[0].Name != "Test Text Field" {
utils.PrintTestError(t, customFields[0].Name, "Test Text Field")
}
if customFields[3].Name != "Test Currency Field" {
utils.PrintTestError(t, customFields[3].Name, "Test Currency Field")
}
}
func TestShouldReturnErrorWithInvalidOrderBy(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Create paged request with invalid orderBy field
pagedRequest := commands.PagedRequestCommand{
Page: 1,
PageSize: 10,
OrderBy: "invalid_column",
SortDirection: commands.ASCENDING,
}
_, _, err := repository.GetPagedCustomFields(pagedRequest)
// Should return an error
if err == nil {
utils.PrintTestError(t, "Expected error for invalid orderBy", nil)
}
if err.Error() != "invalid orderBy" {
utils.PrintTestError(t, err.Error(), "invalid orderBy")
}
}
func TestShouldValidateOrderByColumn(t *testing.T) {
repository := NewCustomFieldRepository(nil)
// Valid orderBy values
if err := repository.validateOrderBy("name"); err != nil {
utils.PrintTestError(t, err, nil)
}
if err := repository.validateOrderBy("type"); err != nil {
utils.PrintTestError(t, err, nil)
}
if err := repository.validateOrderBy("description"); err != nil {
utils.PrintTestError(t, err, nil)
}
// Invalid orderBy values
if err := repository.validateOrderBy("id"); err == nil {
utils.PrintTestError(t, "Expected error for invalid orderBy 'id'", nil)
}
if err := repository.validateOrderBy("created_at"); err == nil {
utils.PrintTestError(t, "Expected error for invalid orderBy 'created_at'", nil)
}
}
func TestShouldGetCustomFieldById(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
db := GetDB()
// Get a custom field from the test DB to use its ID
var expectedCustomField models.CustomField
db.Where("name = ?", "Test Text Field").First(&expectedCustomField)
// Get the custom field by ID
customField, err := repository.GetCustomFieldById(expectedCustomField.ID)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Validate the fetched custom field
if customField.ID != expectedCustomField.ID {
utils.PrintTestError(t, customField.ID, expectedCustomField.ID)
}
if customField.Name != "Test Text Field" {
utils.PrintTestError(t, customField.Name, "Test Text Field")
}
if customField.Type != models.TEXT {
utils.PrintTestError(t, string(customField.Type), string(models.TEXT))
}
if customField.Description != "A test text field" {
utils.PrintTestError(t, customField.Description, "A test text field")
}
}
func TestShouldGetCustomFieldByIdWithSelectType(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
db := GetDB()
// Get a SELECT type custom field from the test DB
var expectedCustomField models.CustomField
db.Where("name = ?", "Test Select Field").First(&expectedCustomField)
// Get the custom field by ID
customField, err := repository.GetCustomFieldById(expectedCustomField.ID)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Validate the fetched custom field
if customField.ID != expectedCustomField.ID {
utils.PrintTestError(t, customField.ID, expectedCustomField.ID)
}
if customField.Name != "Test Select Field" {
utils.PrintTestError(t, customField.Name, "Test Select Field")
}
if customField.Type != models.SELECT {
utils.PrintTestError(t, string(customField.Type), string(models.SELECT))
}
// Verify that options are preloaded
if len(customField.Options) != 2 {
utils.PrintTestError(t, len(customField.Options), 2)
return
}
// Verify option values
foundOption1 := false
foundOption2 := false
for _, option := range customField.Options {
if option.Value == "Option 1" {
foundOption1 = true
}
if option.Value == "Option 2" {
foundOption2 = true
}
}
if !foundOption1 {
utils.PrintTestError(t, "Missing Option 1", "Option 1 should be present")
}
if !foundOption2 {
utils.PrintTestError(t, "Missing Option 2", "Option 2 should be present")
}
}
func TestShouldReturnErrorForNonExistentCustomFieldId(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
// Try to get a custom field with a non-existent ID
nonExistentId := uint(999)
_, err := repository.GetCustomFieldById(nonExistentId)
// Should return an error
if err == nil {
utils.PrintTestError(t, "Expected error for non-existent ID", nil)
}
}
func TestShouldDeleteCustomField(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
db := GetDB()
// Get a TEXT type custom field to delete
var customField models.CustomField
db.Where("name = ?", "Test Text Field").First(&customField)
customFieldId := customField.ID
// Delete the custom field
err := repository.DeleteCustomField(customFieldId)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Try to get the deleted custom field - should return error
_, err = repository.GetCustomFieldById(customFieldId)
if err == nil {
utils.PrintTestError(t, "Expected error when getting deleted custom field", nil)
}
}
func TestShouldDeleteCustomFieldWithOptions(t *testing.T) {
defer teardownCustomFieldRepositoryTest()
setupCustomFieldRepositoryTest()
repository := NewCustomFieldRepository(nil)
db := GetDB()
// Get a SELECT type custom field with options
var customField models.CustomField
db.Where("name = ?", "Test Select Field").First(&customField)
customFieldId := customField.ID
// Verify options exist before deletion
var optionsCount int64
db.Model(&models.CustomFieldOption{}).Where("custom_field_id = ?", customFieldId).Count(&optionsCount)
if optionsCount == 0 {
utils.PrintTestError(t, "Expected options to exist before deletion", nil)
return
}
// Delete the custom field
err := repository.DeleteCustomField(customFieldId)
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
// Verify custom field was deleted
var customFieldExists bool
err = db.Model(&models.CustomField{}).
Select("count(*) > 0").
Where("id = ?", customFieldId).
Find(&customFieldExists).
Error
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
if customFieldExists {
utils.PrintTestError(t, "Custom field should be deleted", nil)
}
// Verify options were deleted
var optionsExist bool
err = db.Model(&models.CustomFieldOption{}).
Select("count(*) > 0").
Where("custom_field_id = ?", customFieldId).
Find(&optionsExist).
Error
if err != nil {
utils.PrintTestError(t, err, nil)
return
}
if optionsExist {
utils.PrintTestError(t, "Custom field options should be deleted", nil)
}
}
+3
View File
@@ -86,6 +86,9 @@ func MakeMigrations() error {
err := db.AutoMigrate(
&models.RefreshToken{},
&models.User{},
&models.CustomField{},
&models.CustomFieldValue{},
&models.CustomFieldOption{},
&models.Receipt{},
&models.Item{},
&models.FileData{},
+5
View File
@@ -155,6 +155,11 @@ func (repository ReceiptRepository) UpdateReceipt(id string, command commands.Up
return txErr
}
txErr = tx.Model(&currentReceipt).Association("CustomFields").Replace(&updatedReceipt.CustomFields)
if txErr != nil {
return txErr
}
for _, item := range updatedReceipt.ReceiptItems {
txErr = tx.Model(&item).Association("Categories").Replace(&item.Categories)
if txErr != nil {
+21
View File
@@ -0,0 +1,21 @@
package routers
import (
"receipt-wrangler/api/internal/handlers"
"receipt-wrangler/api/internal/middleware"
jwtmiddleware "github.com/auth0/go-jwt-middleware/v2"
"github.com/go-chi/chi/v5"
)
func BuildCustomFieldRouter(tokenValidator *jwtmiddleware.JWTMiddleware) *chi.Mux {
router := chi.NewRouter()
router.Use(middleware.MoveJWTCookieToHeader, tokenValidator.CheckJWT)
router.Get("/{id}", handlers.GetCustomFieldById)
router.Delete("/{id}", handlers.DeleteCustomField)
router.Post("/getPagedCustomFields", handlers.GetPagedCustomFields)
router.Post("/", handlers.CreateCustomField)
return router
}
+4
View File
@@ -124,5 +124,9 @@ func BuildRootRouter() *chi.Mux {
exportRouter := BuildExportRouter(tokenValidatorMiddleware)
rootRouter.Mount("/api/export", exportRouter)
// Custom Field router
customFieldRouter := BuildCustomFieldRouter(tokenValidatorMiddleware)
rootRouter.Mount("/api/customField", customFieldRouter)
return rootRouter
}
+238
View File
@@ -309,6 +309,102 @@ paths:
$ref: "#/components/responses/Internal"
security:
- bearerAuth: [ ]
/customField/:
post:
tags:
- CustomField
summary: Create custom field
description: This will create a custom field
operationId: createCustomField
requestBody:
description: Custom field to create
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertCustomFieldCommand"
responses:
200:
description: Created custom field
content:
application/json:
schema:
$ref: "#/components/schemas/CustomField"
403:
$ref: "#/components/responses/Forbidden"
500:
$ref: "#/components/responses/Internal"
security:
- bearerAuth: [ ]
/customField/{customFieldId}:
parameters:
- in: path
name: customFieldId
schema:
type: integer
required: true
description: Custom field Id to get
get:
tags:
- CustomField
summary: Get custom field
description: This will get a custom field by id
operationId: getCustomFieldById
responses:
200:
description: The custom field
content:
application/json:
schema:
$ref: "#/components/schemas/CustomField"
403:
$ref: "#/components/responses/Forbidden"
500:
$ref: "#/components/responses/Internal"
security:
- bearerAuth: [ ]
delete:
tags:
- CustomField
summary: Delete custom field
description: This will delete a custom field by id
operationId: deleteCustomField
responses:
200:
$ref: "#/components/responses/Ok"
403:
$ref: "#/components/responses/Forbidden"
500:
$ref: "#/components/responses/Internal"
security:
- bearerAuth: [ ]
/customField/getPagedCustomFields:
post:
tags:
- CustomField
summary: Get paged custom fields
description: This will return paged custom fields
operationId: getPagedCustomFields
requestBody:
description: Paging and sorting data
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PagedRequestCommand"
responses:
200:
description: Paged categories
content:
application/json:
schema:
$ref: "#/components/schemas/PagedData"
403:
$ref: "#/components/responses/Forbidden"
500:
$ref: "#/components/responses/Internal"
security:
- bearerAuth: [ ]
/export/{groupId}:
post:
tags:
@@ -2477,6 +2573,14 @@ components:
type: string
enum:
- "CSV"
CustomFieldType:
type: string
enum:
- "TEXT"
- "DATE"
- "SELECT"
- "CURRENCY"
- "BOOLEAN"
Activity:
type: object
required:
@@ -2893,6 +2997,11 @@ components:
- name
- paidByUserId
- status
- customFields
- categories
- tags
- receiptItems
- comments
- id
type: object
properties:
@@ -2909,6 +3018,11 @@ components:
description: Comments associated to receipt
items:
$ref: "#/components/schemas/Comment"
customFields:
type: array
description: Custom fields associated to receipt
items:
$ref: "#/components/schemas/CustomFieldValue"
createdAt:
type: string
createdBy:
@@ -3377,6 +3491,7 @@ components:
- $ref: "#/components/schemas/ReceiptProcessingSettings"
- $ref: "#/components/schemas/SystemEmail"
- $ref: "#/components/schemas/Activity"
- $ref: "#/components/schemas/CustomField"
totalCount:
type: integer
FeatureConfig:
@@ -3942,6 +4057,11 @@ components:
description: Comments associated to receipt
items:
$ref: "#/components/schemas/UpsertCommentCommand"
customFields:
type: array
description: Custom fields associated to receipt
items:
$ref: "#/components/schemas/UpsertCustomFieldCommand"
UpsertItemCommand:
type: object
required:
@@ -4361,6 +4481,124 @@ components:
version:
type: string
description: Version
CustomFieldOption:
allOf:
- $ref: "#/components/schemas/BaseModel"
- type: object
required:
- customFieldId
properties:
value:
type: string
description: Custom Field Option value
customFieldId:
type: integer
description: Custom Field Id
UpsertCustomFieldOptionCommand:
type: object
required:
- customFieldId
properties:
value:
type: string
description: Custom Field Option value
customFieldId:
type: integer
description: Custom Field Id
CustomField:
allOf:
- $ref: "#/components/schemas/BaseModel"
- type: object
required:
- name
- type
properties:
name:
type: string
description: Custom Field name
type:
$ref: "#/components/schemas/CustomFieldType"
description:
type: string
description: Custom Field description
options:
type: array
items:
$ref: "#/components/schemas/CustomFieldOption"
UpsertCustomFieldCommand:
type: object
required:
- name
- type
properties:
name:
type: string
description: Custom Field name
type:
$ref: "#/components/schemas/CustomFieldType"
description:
type: string
description: Custom Field description
options:
type: array
items:
$ref: "#/components/schemas/UpsertCustomFieldOptionCommand"
CustomFieldValue:
allOf:
- $ref: "#/components/schemas/BaseModel"
- type: object
required:
- receiptId
- customFieldId
properties:
receiptId:
type: integer
description: Receipt Id
customFieldId:
type: integer
description: Custom Field ID
stringValue:
type: string
description: Custom Field String Value
dateValue:
type: string
description: Custom Field Date Value
selectValue:
type: integer
description: Custom Field Select Value
currencyValue:
type: string
description: Custom Field Currency Value
booleanValue:
type: boolean
description: Custom Field Boolean Value
UpsertCustomFieldValueCommand:
type: object
required:
- receiptId
- customFieldId
properties:
receiptId:
type: integer
description: Receipt Id
customFieldId:
type: integer
description: Custom Field ID
stringValue:
type: string
description: Custom Field String Value
dateValue:
type: string
description: Custom Field Date Value
selectValue:
type: integer
description: Custom Field Select Value
currencyValue:
type: string
description: Custom Field Currency Value
booleanValue:
type: boolean
description: Custom Field Boolean Value
InternalErrorResponse:
type: object