diff --git a/crm/repository/content.go b/crm/repository/content.go index d34e5b723..efc6f2f77 100644 --- a/crm/repository/content.go +++ b/crm/repository/content.go @@ -2,19 +2,28 @@ package repository import ( "context" - "github.com/crusttech/crust/crm/types" + "strings" + "time" + + "encoding/json" + + "github.com/pkg/errors" "github.com/titpetric/factory" + + "github.com/crusttech/crust/crm/types" ) type ( - Content interface { - With(ctx context.Context) Content + ContentRepository interface { + With(ctx context.Context, db *factory.DB) ContentRepository FindByID(id uint64) (*types.Content, error) Find() ([]*types.Content, error) Create(mod *types.Content) (*types.Content, error) Update(mod *types.Content) (*types.Content, error) DeleteByID(id uint64) error + + Fields(mod *types.Content) ([]*types.ContentColumn, error) } content struct { @@ -22,13 +31,13 @@ type ( } ) -func NewContent(ctx context.Context) Content { - return (&content{}).With(ctx) +func Content(ctx context.Context, db *factory.DB) ContentRepository { + return (&content{}).With(ctx, db) } -func (r *content) With(ctx context.Context) Content { +func (r *content) With(ctx context.Context, db *factory.DB) ContentRepository { return &content{ - repository: r.repository.With(ctx), + repository: r.repository.With(ctx, db), } } @@ -36,24 +45,80 @@ func (r *content) With(ctx context.Context) Content { func (r *content) FindByID(id uint64) (*types.Content, error) { mod := &types.Content{} - return mod, r.db().Get(mod, "SELECT * FROM crm_module_content WHERE id=?", id) + return mod, r.db().Get(mod, "SELECT * FROM crm_content WHERE id=? and deleted_at IS NULL", id) } func (r *content) Find() ([]*types.Content, error) { mod := make([]*types.Content, 0) - return mod, r.db().Select(&mod, "SELECT * FROM crm_module_content ORDER BY id DESC") + return mod, r.db().Select(&mod, "SELECT * FROM crm_content WHERE deleted_at IS NULL ORDER BY id DESC") } func (r *content) Create(mod *types.Content) (*types.Content, error) { mod.ID = factory.Sonyflake.NextID() - return mod, r.db().Insert("crm_module_content", mod) + mod.CreatedAt = time.Now() + + fields := make([]types.ContentColumn, 0) + if err := json.Unmarshal(mod.Fields, &fields); err != nil { + return nil, errors.Wrap(err, "No content") + } + + for _, v := range fields { + v.ContentID = mod.ID + if err := r.db().Replace("crm_content_column", v); err != nil { + return nil, errors.Wrap(err, "Error adding columns") + } + } + + return mod, r.db().Insert("crm_content", mod) } func (r *content) Update(mod *types.Content) (*types.Content, error) { - return mod, r.db().Replace("crm_module_content", mod) + now := time.Now() + mod.UpdatedAt = &now + + fields := make([]types.ContentColumn, 0) + if err := json.Unmarshal(mod.Fields, &fields); err != nil { + return nil, errors.Wrap(err, "Error when saving content, no content") + } + + for _, v := range fields { + v.ContentID = mod.ID + if err := r.db().Replace("crm_content_column", v); err != nil { + return nil, errors.Wrap(err, "Error adding columns to database") + } + } + + return mod, r.db().Replace("crm_content", mod) } func (r *content) DeleteByID(id uint64) error { - _, err := r.db().Exec("DELETE FROM crm_module_content WHERE id=?", id) + _, err := r.db().Exec("update crm_content set deleted_at=? where id=?", time.Now(), id) return err } + +func (r *content) Fields(content *types.Content) ([]*types.ContentColumn, error) { + result := make([]*types.ContentColumn, 0) + module := Module(r.ctx, r.db()) + + mod, err := module.FindByID(content.ModuleID) + if err != nil { + return result, err + } + + fieldNames, err := module.FieldNames(mod) + if err != nil { + return result, err + } + if len(fieldNames) == 0 { + return result, errors.New("Module has no fields") + } + + order := "FIELD(column_name" + strings.Repeat(",?", len(fieldNames)) + ")" + args := []interface{}{ + content.ID, + } + for _, v := range fieldNames { + args = append(args, v) + } + return result, r.db().Select(&result, "select * from crm_content_column where content_id=? order by "+order, args...) +} diff --git a/crm/repository/content_test.go b/crm/repository/content_test.go index aa3a70821..89b3dea5d 100644 --- a/crm/repository/content_test.go +++ b/crm/repository/content_test.go @@ -3,25 +3,15 @@ package repository import ( "context" "encoding/json" - "github.com/crusttech/crust/crm/types" "testing" + + "github.com/pkg/errors" + + "github.com/crusttech/crust/crm/types" ) -type testContentRow struct { - Name string `db:"name"` - Value string `db:"value"` -} - func TestContent(t *testing.T) { - repository := NewContent(context.TODO()).With(context.Background()) - - // clean up tables - { - for _, name := range []string{"crm_module", "crm_module_content"} { - _, err := db().Exec("truncate " + name) - assert(t, err == nil, "Error when clearing "+name+": %+v", err) - } - } + repository := Content(context.TODO(), nil).With(context.Background(), nil) fields, err := json.Marshal([]types.Field{ types.Field{ @@ -42,7 +32,7 @@ func TestContent(t *testing.T) { // set up a module { - _, err := NewModule(context.TODO()).With(context.Background()).Create(module) + _, err := Module(context.TODO(), nil).With(context.Background(), nil).Create(module) assert(t, err == nil, "Error when creating module: %+v", err) assert(t, module.ID > 0, "Expected auto generated ID") } @@ -51,9 +41,15 @@ func TestContent(t *testing.T) { ModuleID: module.ID, } (&content.Fields).Scan(func() []byte { - b, _ := json.Marshal([]testContentRow{ - testContentRow{"name", "Tit Petric"}, - testContentRow{"email", "tit.petric@example.com"}, + b, _ := json.Marshal([]types.ContentColumn{ + types.ContentColumn{ + Name: "name", + Value: "Tit Petric", + }, + types.ContentColumn{ + Name: "email", + Value: "tit.petric@example.com", + }, }) return b }()) @@ -72,9 +68,10 @@ func TestContent(t *testing.T) { assert(t, ms.ID == m.ID, "Expected ID from database to match, %d != %d", m.ID, ms.ID) assert(t, ms.ModuleID == m.ModuleID, "Expected Module ID from database to match, %d != %d", m.ModuleID, ms.ModuleID) - fields := make([]testContentRow, 0) - err = json.Unmarshal(ms.Fields, &fields) - assert(t, err == nil, "Didn't expect error when unmarshalling: %+v", err) + fields, err := repository.Fields(ms) + // fields := make([]testContentRow, 0) + // err = json.Unmarshal(ms.Fields, &fields) + assert(t, err == nil, "%+v", errors.Wrap(err, "Didn't expect error when unmarshalling")) assert(t, len(fields) == 2, "Expected different field count: %d != %d", 2, len(fields)) assert(t, fields[0].Name == "name", "Expected field.0 type = name, got %s", fields[0].Name) assert(t, fields[1].Name == "email", "Expected field.1 type = email, got %s", fields[1].Name) @@ -112,7 +109,7 @@ func TestContent(t *testing.T) { { ms, err := repository.Find() assert(t, err == nil, "Error when retrieving contents: %+v", err) - assert(t, len(ms) == 0, "Expected one content, got %d", len(ms)) + assert(t, len(ms) == 0, "Expected no content, got %d", len(ms)) } } diff --git a/crm/repository/field.go b/crm/repository/field.go index 3811dfab6..0e3ea8aec 100644 --- a/crm/repository/field.go +++ b/crm/repository/field.go @@ -2,12 +2,15 @@ package repository import ( "context" + + "github.com/titpetric/factory" + "github.com/crusttech/crust/crm/types" ) type ( - Field interface { - With(ctx context.Context) Field + FieldRepository interface { + With(ctx context.Context, db *factory.DB) FieldRepository FindByType(t string) (*types.Field, error) Find() ([]*types.Field, error) @@ -18,24 +21,24 @@ type ( } ) -func NewField(ctx context.Context) Field { - return (&field{}).With(ctx) +func Field(ctx context.Context, db *factory.DB) FieldRepository { + return (&field{}).With(ctx, db) } -func (r *field) With(ctx context.Context) Field { +func (r *field) With(ctx context.Context, db *factory.DB) FieldRepository { return &field{ - repository: r.repository.With(ctx), + repository: r.repository.With(ctx, db), } } // FindByName returns field with a given name func (f *field) FindByType(t string) (*types.Field, error) { res := &types.Field{} - return res, f.db().Get(res, "SELECT * from crm_fields where field_type=?", t) + return res, f.db().Get(res, "SELECT * from crm_field where field_type=?", t) } // Find returns all known fields func (f *field) Find() ([]*types.Field, error) { mod := make([]*types.Field, 0) - return mod, f.db().Select(&mod, "SELECT * FROM crm_fields ORDER BY field_name ASC") + return mod, f.db().Select(&mod, "SELECT * FROM crm_field ORDER BY field_name ASC") } diff --git a/crm/repository/field_test.go b/crm/repository/field_test.go index 85d736212..4ff7baa88 100644 --- a/crm/repository/field_test.go +++ b/crm/repository/field_test.go @@ -6,8 +6,7 @@ import ( ) func TestField(t *testing.T) { - - repository := NewField(context.TODO()).With(context.Background()) + repository := Field(context.TODO(), nil).With(context.Background(), nil) { // fetch all fields diff --git a/crm/repository/main_test.go b/crm/repository/main_test.go index 56e71c1ff..12906a0df 100644 --- a/crm/repository/main_test.go +++ b/crm/repository/main_test.go @@ -31,6 +31,16 @@ func TestMain(m *testing.M) { db := factory.Database.MustGet() db.Profiler = &factory.Database.ProfilerStdout + // clean up tables + { + for _, name := range []string{"crm_module", "crm_content", "crm_content_column"} { + _, err := db.Exec("truncate " + name) + if err != nil { + panic("Error when clearing "+name+": "+err.Error()) + } + } + } + os.Exit(m.Run()) } diff --git a/crm/repository/module.go b/crm/repository/module.go index 5d4e894a8..c554eb28e 100644 --- a/crm/repository/module.go +++ b/crm/repository/module.go @@ -2,19 +2,27 @@ package repository import ( "context" - "github.com/crusttech/crust/crm/types" + "encoding/json" + "time" + + "github.com/pkg/errors" "github.com/titpetric/factory" + + "github.com/crusttech/crust/crm/types" ) type ( - Module interface { - With(ctx context.Context) Module + ModuleRepository interface { + With(ctx context.Context, db *factory.DB) ModuleRepository FindByID(id uint64) (*types.Module, error) Find() ([]*types.Module, error) Create(mod *types.Module) (*types.Module, error) Update(mod *types.Module) (*types.Module, error) DeleteByID(id uint64) error + + Fields(mod *types.Module) ([]*types.ModuleField, error) + FieldNames(mod *types.Module) ([]string, error) } module struct { @@ -22,13 +30,13 @@ type ( } ) -func NewModule(ctx context.Context) Module { - return (&module{}).With(ctx) +func Module(ctx context.Context, db *factory.DB) ModuleRepository { + return (&module{}).With(ctx, db) } -func (r *module) With(ctx context.Context) Module { +func (r *module) With(ctx context.Context, db *factory.DB) ModuleRepository { return &module{ - repository: r.repository.With(ctx), + repository: r.repository.With(ctx, db), } } @@ -46,10 +54,37 @@ func (r *module) Find() ([]*types.Module, error) { func (r *module) Create(mod *types.Module) (*types.Module, error) { mod.ID = factory.Sonyflake.NextID() + mod.CreatedAt = time.Now() + + fields := make([]types.ModuleField, 0) + if err := json.Unmarshal(mod.Fields, &fields); err != nil { + return nil, errors.Wrap(err, "No fields") + } + + for _, v := range fields { + v.ModuleID = mod.ID + if err := r.db().Replace("crm_module_form", v); err != nil { + return nil, errors.Wrap(err, "Error adding module fields") + } + } return mod, r.db().Insert("crm_module", mod) } func (r *module) Update(mod *types.Module) (*types.Module, error) { + now := time.Now() + mod.UpdatedAt = &now + + fields := make([]types.ModuleField, 0) + if err := json.Unmarshal(mod.Fields, &fields); err != nil { + return nil, errors.Wrap(err, "No fields") + } + + for _, v := range fields { + v.ModuleID = mod.ID + if err := r.db().Replace("crm_module_form", v); err != nil { + return nil, errors.Wrap(err, "Error adding module fields") + } + } return mod, r.db().Replace("crm_module", mod) } @@ -57,3 +92,21 @@ func (r *module) DeleteByID(id uint64) error { _, err := r.db().Exec("DELETE FROM crm_module WHERE id=?", id) return err } + +func (r *module) Fields(mod *types.Module) ([]*types.ModuleField, error) { + fields := make([]*types.ModuleField, 0) + return fields, r.db().Select(&fields, "select * from crm_module_form where module_id=? order by place asc", mod.ID) +} + +// FieldNames returns a slice of field names, used for ordering content row columns +func (r *module) FieldNames(mod *types.Module) ([]string, error) { + if fields, err := r.Fields(mod); err != nil { + return []string{}, err + } else { + result := make([]string, len(fields)) + for k, v := range fields { + result[k] = v.Name + } + return result, nil + } +} diff --git a/crm/repository/module_test.go b/crm/repository/module_test.go index 61104bcbd..ed5d971a6 100644 --- a/crm/repository/module_test.go +++ b/crm/repository/module_test.go @@ -7,19 +7,15 @@ import ( ) func TestModule(t *testing.T) { - - repository := NewModule(context.TODO()).With(context.Background()) - - // clean up tables - { - _, err := db().Exec("truncate crm_module") - assert(t, err == nil, "Error when clearing crm_module: %+v", err) - } + repository := Module(context.TODO(), nil).With(context.Background(), nil) // the module object we're working with module := &types.Module{ Name: "Test", } + (&module.Fields).Scan([]byte("[]")) + + prevModuleCount := 0 { // create module @@ -54,8 +50,8 @@ func TestModule(t *testing.T) { { ms, err := repository.Find() assert(t, err == nil, "Error when retrieving modules: %+v", err) - assert(t, len(ms) == 1, "Expected one module, got %d", len(ms)) - assert(t, ms[0].Name == m.Name, "Expected module name to match, %s != %s", m.Name, ms[0].Name) + assert(t, len(ms) >= 1, "Expected at least one module, got %d", len(ms)) + prevModuleCount = len(ms) } // re-fetch module @@ -68,7 +64,7 @@ func TestModule(t *testing.T) { { ms, err := repository.Find() assert(t, err == nil, "Error when retrieving modules: %+v", err) - assert(t, len(ms) == 0, "Expected no modules, got %d", len(ms)) + assert(t, len(ms) < prevModuleCount, "Expected modules count to decrease after deletion, %d < %d", len(ms), prevModuleCount) } } diff --git a/crm/repository/repository.go b/crm/repository/repository.go index 2ca484a49..236880d57 100644 --- a/crm/repository/repository.go +++ b/crm/repository/repository.go @@ -2,45 +2,44 @@ package repository import ( "context" + "github.com/titpetric/factory" + + "github.com/crusttech/crust/internal/auth" ) type ( repository struct { ctx context.Context - - // Get database handle - dbh func(ctxs ...context.Context) *factory.DB + dbh *factory.DB } ) -var _db *factory.DB - -// DB returns a repository-wide singleton DB handle -func DB(ctxs ...context.Context) *factory.DB { - if _db == nil { - _db = factory.Database.MustGet() - } - for _, ctx := range ctxs { - _db = _db.With(ctx) - break - } - return _db +// DB produces a contextual DB handle +func DB(ctx context.Context) *factory.DB { + return factory.Database.MustGet().With(ctx) } -// With updates repository and database contexts -func (r *repository) With(ctx context.Context) *repository { - res := &repository{ +func Identity(ctx context.Context) uint64 { + return auth.GetIdentityFromContext(ctx).Identity() +} + +func (r *repository) With(ctx context.Context, db *factory.DB) *repository { + return &repository{ ctx: ctx, - dbh: DB, + dbh: db, } - if r != nil { - res.dbh = r.dbh - } - return res +} + +// Context returns current active repository context +func (r *repository) Context() context.Context { + return r.ctx } // db returns context-aware db handle func (r *repository) db() *factory.DB { - return r.dbh(r.ctx) + if r.dbh != nil { + return r.dbh + } + return DB(r.ctx) } diff --git a/crm/repository/repository_test.go b/crm/repository/repository_test.go new file mode 100644 index 000000000..8db675bf1 --- /dev/null +++ b/crm/repository/repository_test.go @@ -0,0 +1,11 @@ +package repository + +import ( + "context" + "testing" +) + +func TestEvents(t *testing.T) { + repo := &repository{} + repo.With(context.Background(), nil) +} diff --git a/crm/service/content.go b/crm/service/content.go index 6b13a5e2b..c7765b2ab 100644 --- a/crm/service/content.go +++ b/crm/service/content.go @@ -2,13 +2,18 @@ package service import ( "context" + + "github.com/titpetric/factory" + "github.com/crusttech/crust/crm/repository" "github.com/crusttech/crust/crm/types" ) type ( content struct { - repository repository.Content + db *factory.DB + ctx context.Context + repository repository.ContentRepository } ContentService interface { @@ -24,14 +29,15 @@ type ( ) func Content() ContentService { - return &content{ - repository: repository.NewContent(context.Background()), - } + return (&content{}).With(context.Background()) } func (s *content) With(ctx context.Context) ContentService { + db := repository.DB(ctx) return &content{ - repository: s.repository.With(ctx), + db: db, + ctx: ctx, + repository: s.repository.With(ctx, db), } } diff --git a/crm/service/field.go b/crm/service/field.go index 14edbef9f..76478243e 100644 --- a/crm/service/field.go +++ b/crm/service/field.go @@ -8,7 +8,9 @@ import ( type ( field struct { - repository repository.Field + db *factory.DB + ctx context.Context + repository repository.FieldRepository } FieldService interface { @@ -19,14 +21,15 @@ type ( ) func Field() FieldService { - return &field{ - repository: repository.NewField(context.Background()), - } + return (&field{}).With(context.Background()) } func (s *field) With(ctx context.Context) FieldService { + db := repository.DB(ctx) return &field{ - repository: s.repository.With(ctx), + db: db, + ctx: ctx, + repository: s.repository.With(ctx, db), } } diff --git a/crm/service/module.go b/crm/service/module.go index 5268d82d3..92a0e297b 100644 --- a/crm/service/module.go +++ b/crm/service/module.go @@ -8,7 +8,9 @@ import ( type ( module struct { - repository repository.Module + db *factory.DB + ctx context.Context + repository repository.ModuleRepository } ModuleService interface { @@ -24,14 +26,14 @@ type ( ) func Module() ModuleService { - return &module{ - repository: repository.NewModule(context.Background()), - } + return (&module{}).With(context.Background()) } func (s *module) With(ctx context.Context) ModuleService { return &module{ - repository: s.repository.With(ctx), + db: db, + ctx: ctx, + repository: s.repository.With(ctx, db), } } diff --git a/crm/types/field.go b/crm/types/field.go deleted file mode 100644 index 6d5bc0cb0..000000000 --- a/crm/types/field.go +++ /dev/null @@ -1,12 +0,0 @@ -package types - -/* If you want to edit this file by hand, remove codegen/[project]/types/index.php */ - -type ( - // Fields - CRM input field definitions - Field struct { - Name string `json:"field_name" db:"field_name"` - Type string `json:"field_type" db:"field_type"` - Template string `json:"field_template,omitempty" db:"field_template"` - } -) diff --git a/crm/types/module.go b/crm/types/module.go deleted file mode 100644 index 157fb851a..000000000 --- a/crm/types/module.go +++ /dev/null @@ -1,32 +0,0 @@ -package types - -/* If you want to edit this file by hand, remove codegen/[project]/types/index.php */ - -import ( - "github.com/jmoiron/sqlx/types" -) - -type ( - // Modules - CRM module definitions - Module struct { - ID uint64 `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Fields types.JSONText `json:"fields" db:"json"` - } - - // Modules - CRM module definitions - ModuleField struct { - Name string `json:"name" db:"name"` - Title string `json:"title" db:"title"` - Kind string `json:"kind" db:"kind"` - GDPR bool `json:"gdpr" db:"gdpr"` - Show bool `json:"show" db:"show"` - } - - // Modules - CRM module definitions - Content struct { - ID uint64 `json:"id" db:"id"` - ModuleID uint64 `json:"module_id" db:"module_id"` - Fields types.JSONText `json:"json" db:"json"` - } -) diff --git a/crm/types/types.go b/crm/types/types.go new file mode 100644 index 000000000..9fcc8d1ac --- /dev/null +++ b/crm/types/types.go @@ -0,0 +1,60 @@ +package types + +import ( + "time" + + "github.com/jmoiron/sqlx/types" +) + +type ( + // Content is a stored row in the `content` table + Content struct { + ID uint64 `json:"id" db:"id"` + ModuleID uint64 `json:"moduleID" db:"module_id"` + + Fields types.JSONText `json:"fields,omitempty" db:"-"` + + CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"` + UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` + DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` + } + + // ContentColumn is a stored row in the `content_column` table + ContentColumn struct { + ContentID uint64 `json:"contentID" db:"content_id"` + Name string `json:"name" db:"column_name"` + Value string `json:"value" db:"column_value"` + } + + // Field - CRM input field definitions + Field struct { + Name string `json:"name" db:"field_name"` + Type string `json:"type" db:"field_type"` + Template string `json:"template,omitempty" db:"field_template"` + } + + // Modules - CRM module definitions + Module struct { + ID uint64 `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Fields types.JSONText `json:"fields" db:"json"` + + CreatedAt time.Time `db:"created_at" json:"createdAt,omitempty"` + UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` + DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` + } + + // Modules - CRM module definitions + ModuleField struct { + ModuleID uint64 `json:"moduleID" db:"module_id"` + Place int `json:"-" db:"place"` + + Kind string `json:"kind" db:"kind"` + Name string `json:"name" db:"name"` + Label string `json:"label" db:"label"` + HelpText string `json:"helpText,omitempty" db:"help_text"` + Default string `json:"defaultValue,omitempty" db:"default_value"` + MaxLength int `json:"maxLength" db:"max_length"` + Private bool `json:"isPrivate" db:"is_private"` + } +)