diff --git a/api/compose/spec.json b/api/compose/spec.json index 66af9eb76..add300e05 100644 --- a/api/compose/spec.json +++ b/api/compose/spec.json @@ -185,7 +185,7 @@ }, { "title": "Pages", - "description": "Compose module pages", + "description": "Compose pages", "entrypoint": "page", "path": "/namespace/{namespaceID}/page", "authentication": [], @@ -432,7 +432,8 @@ { "imports": [ "sqlxTypes github.com/jmoiron/sqlx/types", - "github.com/crusttech/crust/compose/types" + "github.com/crusttech/crust/compose/types", + "time" ] } ], @@ -449,6 +450,18 @@ "name": "query", "required": false, "title": "Search query" + }, + { + "name": "page", + "type": "uint", + "required": false, + "title": "Page number (0 based)" + }, + { + "name": "perPage", + "type": "uint", + "required": false, + "title": "Returned items per page (default 50)" } ] } @@ -529,6 +542,12 @@ "name": "meta", "required": true, "title": "Module meta data" + }, + { + "type": "*time.Time", + "name": "updatedAt", + "required": false, + "title": "Last update (or creation) date" } ] } diff --git a/api/compose/spec/module.json b/api/compose/spec/module.json index 1ca51e4d8..a123fa355 100644 --- a/api/compose/spec/module.json +++ b/api/compose/spec/module.json @@ -6,7 +6,8 @@ { "imports": [ "sqlxTypes github.com/jmoiron/sqlx/types", - "github.com/crusttech/crust/compose/types" + "github.com/crusttech/crust/compose/types", + "time" ] } ], @@ -36,6 +37,18 @@ "required": false, "title": "Search query", "type": "string" + }, + { + "name": "page", + "required": false, + "title": "Page number (0 based)", + "type": "uint" + }, + { + "name": "perPage", + "required": false, + "title": "Returned items per page (default 50)", + "type": "uint" } ] } @@ -116,6 +129,12 @@ "required": true, "title": "Module meta data", "type": "sqlxTypes.JSONText" + }, + { + "name": "updatedAt", + "required": false, + "title": "Last update (or creation) date", + "type": "*time.Time" } ] } diff --git a/api/compose/spec/page.json b/api/compose/spec/page.json index 19f9bb502..9f70840c7 100644 --- a/api/compose/spec/page.json +++ b/api/compose/spec/page.json @@ -1,6 +1,6 @@ { "Title": "Pages", - "Description": "Compose module pages", + "Description": "Compose pages", "Interface": "Page", "Struct": [ { diff --git a/compose/internal/repository/module.go b/compose/internal/repository/module.go index f7a9299de..1d377e126 100644 --- a/compose/internal/repository/module.go +++ b/compose/internal/repository/module.go @@ -7,6 +7,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/pkg/errors" "github.com/titpetric/factory" + "gopkg.in/Masterminds/squirrel.v1" "github.com/crusttech/crust/compose/types" ) @@ -15,11 +16,12 @@ type ( ModuleRepository interface { With(ctx context.Context, db *factory.DB) ModuleRepository - FindByID(id uint64) (*types.Module, error) - Find() (types.ModuleSet, error) + FindByID(namespaceID, moduleID uint64) (*types.Module, error) + Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) + FindFields(moduleIDs ...uint64) (ff types.ModuleFieldSet, err error) Create(mod *types.Module) (*types.Module, error) Update(mod *types.Module) (*types.Module, error) - DeleteByID(id uint64) error + DeleteByID(namespaceID, moduleID uint64) error } module struct { @@ -28,60 +30,81 @@ type ( ) const ( - sqlModuleColumns = ` - id, name, json, - created_at, updated_at, deleted_at - ` - sqlModuleSelect = ` - SELECT ` + sqlModuleColumns + ` FROM compose_module WHERE deleted_at IS NULL - ` + ErrModuleNotFound = repositoryError("ModuleNotFound") ) func Module(ctx context.Context, db *factory.DB) ModuleRepository { return (&module{}).With(ctx, db) } -func (r *module) With(ctx context.Context, db *factory.DB) ModuleRepository { +func (r module) With(ctx context.Context, db *factory.DB) ModuleRepository { return &module{ repository: r.repository.With(ctx, db), } } -// @todo: update to accepted DeletedAt column semantics from Messaging - -func (r *module) FindByID(id uint64) (mod *types.Module, err error) { - mod = &types.Module{} - - if err = r.db().Get(mod, sqlModuleSelect+" AND id = ? ", id); err != nil { - return - } - - if mod.Fields, err = r.fields(id); err != nil { - return - } - - return +func (r module) table() string { + return "compose_module" } -func (r *module) Find() (mm types.ModuleSet, err error) { - if err = r.db().Select(&mm, sqlModuleSelect+" ORDER BY id ASC"); err != nil { - return +func (r module) columns() []string { + return []string{ + "id", "rel_namespace", "name", "json", + "created_at", "updated_at", "deleted_at", } - - var ff types.ModuleFieldSet - if ff, err = r.fields(mm.IDs()...); err != nil { - return - } else { - _ = ff.Walk(func(f *types.ModuleField) error { - mm.FindByID(f.ModuleID).Fields = append(mm.FindByID(f.ModuleID).Fields, f) - return nil - }) - } - - return mm, nil } -func (r *module) Create(mod *types.Module) (*types.Module, error) { +func (r module) query() squirrel.SelectBuilder { + return squirrel. + Select(). + From(r.table()). + Where("deleted_at IS NULL") + +} + +func (r module) FindByID(namespaceID, moduleID uint64) (*types.Module, error) { + var ( + query = r.query(). + Columns(r.columns()...). + Where("id = ?", moduleID) + + c = &types.Module{} + ) + + if namespaceID > 0 { + query = query.Where("rel_namespace = ?", namespaceID) + } + + return c, isFound(r.fetchOne(c, query), c.ID > 0, ErrModuleNotFound) +} + +func (r module) Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) { + f = filter + f.PerPage = normalizePerPage(f.PerPage, 5, 100, 50) + + query := r.query() + + if filter.NamespaceID > 0 { + query = query.Where("a.rel_namespace = ?", filter.NamespaceID) + } + + if f.Query != "" { + q := "%" + f.Query + "%" + query = query.Where("name like ?", q) + } + + if f.Count, err = r.count(query); err != nil || f.Count == 0 { + return + } + + query = query. + Columns(r.columns()...). + OrderBy("id ASC") + + return set, f, r.fetchPaged(&set, query, f.Page, f.PerPage) +} + +func (r module) Create(mod *types.Module) (*types.Module, error) { mod.ID = factory.Sonyflake.NextID() mod.CreatedAt = time.Now() @@ -89,10 +112,10 @@ func (r *module) Create(mod *types.Module) (*types.Module, error) { return nil, err } - return mod, r.db().Insert("compose_module", mod) + return mod, r.db().Insert(r.table(), mod) } -func (r *module) Update(mod *types.Module) (*types.Module, error) { +func (r module) Update(mod *types.Module) (*types.Module, error) { now := time.Now() mod.UpdatedAt = &now @@ -100,10 +123,10 @@ func (r *module) Update(mod *types.Module) (*types.Module, error) { return nil, err } - return mod, r.db().Replace("compose_module", mod) + return mod, r.db().Replace(r.table(), mod) } -func (r *module) updateFields(moduleID uint64, ff types.ModuleFieldSet) error { +func (r module) updateFields(moduleID uint64, ff types.ModuleFieldSet) error { // @todo be more selective when deleting if _, err := r.db().Exec("DELETE FROM compose_module_form WHERE module_id = ?", moduleID); err != nil { return errors.Wrap(err, "Error updating module fields") @@ -120,32 +143,24 @@ func (r *module) updateFields(moduleID uint64, ff types.ModuleFieldSet) error { return nil } -func (r *module) DeleteByID(id uint64) error { - _, err := r.db().Exec("DELETE FROM compose_module WHERE id=?", id) +func (r module) DeleteByID(namespaceID, attachmentID uint64) error { + _, err := r.db().Exec( + "UPDATE "+r.table()+" SET deleted_at = NOW() WHERE rel_namespace = ? AND id = ?", + namespaceID, + attachmentID, + ) + return err } -func (r *module) fields(IDs ...uint64) (ff types.ModuleFieldSet, err error) { - if len(IDs) == 0 { +func (r module) FindFields(moduleIDs ...uint64) (ff types.ModuleFieldSet, err error) { + if len(moduleIDs) == 0 { return } - if sql, args, err := sqlx.In("SELECT * FROM compose_module_form WHERE module_id IN (?) ORDER BY module_id AND place", IDs); err != nil { + if sql, args, err := sqlx.In("SELECT * FROM compose_module_form WHERE module_id IN (?) ORDER BY module_id AND place", moduleIDs); err != nil { return nil, err } else { return ff, r.db().Select(&ff, sql, args...) } } - -// // FieldNames returns a slice of field names, used for ordering record row columns -// func (r *module) FieldNames(mod *types.Module) ([]string, error) { -// if fields, err := r.Fields(mod.ID); 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/compose/internal/service/attachment.go b/compose/internal/service/attachment.go index c19057b8d..0d522fb9f 100644 --- a/compose/internal/service/attachment.go +++ b/compose/internal/service/attachment.go @@ -114,7 +114,7 @@ func (svc attachment) Find(filter types.AttachmentFilter) (types.AttachmentSet, } if filter.ModuleID > 0 { - if _, err := svc.moduleSvc.FindByID(filter.ModuleID); err != nil { + if _, err := svc.moduleSvc.FindByID(filter.NamespaceID, filter.ModuleID); err != nil { return nil, filter, err } } @@ -174,7 +174,7 @@ func (svc attachment) CreateRecordAttachment(namespaceID uint64, name string, si var currentUserID uint64 = auth.GetIdentityFromContext(svc.ctx).Identity() - if _, err := svc.moduleSvc.FindByID(moduleID); err != nil { + if _, err := svc.moduleSvc.FindByID(namespaceID, moduleID); err != nil { return nil, err } else if r, err := svc.recordSvc.FindByID(recordID); err != nil { return nil, err diff --git a/compose/internal/service/module.go b/compose/internal/service/module.go index f687335fd..ed723ca76 100644 --- a/compose/internal/service/module.go +++ b/compose/internal/service/module.go @@ -3,7 +3,6 @@ package service import ( "context" - "github.com/pkg/errors" "github.com/titpetric/factory" "github.com/crusttech/crust/compose/internal/repository" @@ -24,12 +23,12 @@ type ( ModuleService interface { With(ctx context.Context) ModuleService - FindByID(moduleID uint64) (*types.Module, error) - Find() (types.ModuleSet, error) + FindByID(namespaceID, moduleID uint64) (*types.Module, error) + Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) Create(module *types.Module) (*types.Module, error) Update(module *types.Module) (*types.Module, error) - DeleteByID(moduleID uint64) error + DeleteByID(namespaceID, moduleID uint64) error } ) @@ -52,75 +51,96 @@ func (svc *module) With(ctx context.Context) ModuleService { } } -func (svc *module) FindByID(id uint64) (m *types.Module, err error) { - if m, err = svc.moduleRepo.FindByID(id); err != nil { +func (svc *module) FindByID(namespaceID, moduleID uint64) (m *types.Module, err error) { + if namespaceID == 0 { + return nil, ErrNamespaceRequired + } + + if m, err = svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil { return } else if !svc.prmSvc.CanReadModule(m) { - return nil, errors.New("not allowed to access this module") + return nil, ErrNoReadPermissions.withStack() + } + + var ff types.ModuleFieldSet + if ff, err = svc.moduleRepo.FindFields(m.ID); err != nil { + return + } else { + _ = ff.Walk(func(f *types.ModuleField) error { + m.Fields = append(m.Fields, f) + return nil + }) } return } -func (svc *module) Find() (mm types.ModuleSet, err error) { - if mm, err = svc.moduleRepo.Find(); err != nil { - return nil, err +func (svc *module) Find(filter types.ModuleFilter) (set types.ModuleSet, f types.ModuleFilter, err error) { + set, f, err = svc.moduleRepo.Find(filter) + if err != nil { + return + } + + set, _ = set.Filter(func(m *types.Module) (bool, error) { + return svc.prmSvc.CanReadModule(m), nil + }) + + // Preload all fields and update all modules + var ff types.ModuleFieldSet + if ff, err = svc.moduleRepo.FindFields(set.IDs()...); err != nil { + return } else { - return mm.Filter(func(m *types.Module) (bool, error) { - return svc.prmSvc.CanReadModule(m), nil + _ = ff.Walk(func(f *types.ModuleField) error { + set.FindByID(f.ModuleID).Fields = append(set.FindByID(f.ModuleID).Fields, f) + return nil }) } + + return } func (svc *module) Create(mod *types.Module) (*types.Module, error) { if !svc.prmSvc.CanCreateModule(crmNamespace()) { - return nil, errors.New("not allowed to create this module") - } - - if len(mod.Fields) == 0 { - return nil, errors.New("Error creating module: no fields") + return nil, ErrNoCreatePermissions.withStack() } return svc.moduleRepo.Create(mod) } -func (svc *module) Update(module *types.Module) (m *types.Module, err error) { - validate := func() error { - if module.ID == 0 { - return errors.New("Error updating module: invalid ID") - } else if m, err = svc.moduleRepo.FindByID(module.ID); err != nil { - return errors.Wrap(err, "Error while loading module for update") - } else { - if !svc.prmSvc.CanUpdateModule(m) { - return errors.New("not allowed to update this module") - } - - module.CreatedAt = m.CreatedAt - } - - if len(module.Fields) == 0 { - return errors.New("Error updating module: no fields") - } - - return nil +func (svc *module) Update(mod *types.Module) (m *types.Module, err error) { + if mod.ID == 0 { + return nil, ErrInvalidID.withStack() } - if err = validate(); err != nil { - return nil, err - } - - return m, svc.db.Transaction(func() (err error) { - m, err = svc.moduleRepo.Update(module) + if m, err = svc.moduleRepo.FindByID(mod.NamespaceID, mod.ID); err != nil { return - }) -} - -func (svc *module) DeleteByID(ID uint64) error { - if m, err := svc.moduleRepo.FindByID(ID); err != nil { - return errors.Wrap(err, "could not delete module") - } else if !svc.prmSvc.CanDeleteModule(m) { - return errors.New("not allowed to delete this module") } - return svc.moduleRepo.DeleteByID(ID) + if isStale(mod.UpdatedAt, m.UpdatedAt, m.CreatedAt) { + return nil, ErrStaleData.withStack() + } + + if !svc.prmSvc.CanUpdateModule(m) { + return nil, ErrNoUpdatePermissions.withStack() + } + + m.Name = mod.Name + m.Meta = mod.Meta + m.Fields = mod.Fields + + return svc.moduleRepo.Update(m) +} + +func (svc *module) DeleteByID(namespaceID, moduleID uint64) error { + if namespaceID == 0 { + return ErrNamespaceRequired.withStack() + } + + if c, err := svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil { + return err + } else if !svc.prmSvc.CanDeleteModule(c) { + return ErrNoDeletePermissions.withStack() + } + + return svc.moduleRepo.DeleteByID(namespaceID, moduleID) } diff --git a/compose/internal/service/record.go b/compose/internal/service/record.go index 1b9a44445..6e70bb8a9 100644 --- a/compose/internal/service/record.go +++ b/compose/internal/service/record.go @@ -85,9 +85,10 @@ func (svc *record) FindByID(recordID uint64) (r *types.Record, err error) { func (svc *record) Report(moduleID uint64, metrics, dimensions, filter string) (out interface{}, err error) { var module *types.Module + var namespaceID uint64 = 0 err = svc.db.Transaction(func() (err error) { - if module, err = svc.moduleRepo.FindByID(moduleID); err != nil { + if module, err = svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil { return } else if !svc.prmSvc.CanReadRecord(module) { return errors.New("not allowed to access this record") @@ -102,9 +103,10 @@ func (svc *record) Report(moduleID uint64, metrics, dimensions, filter string) ( func (svc *record) Find(moduleID uint64, filter, sort string, page, perPage int) (rsp *repository.FindResponse, err error) { var module *types.Module + var namespaceID uint64 = 0 err = svc.db.Transaction(func() (err error) { - if module, err = svc.moduleRepo.FindByID(moduleID); err != nil { + if module, err = svc.moduleRepo.FindByID(namespaceID, moduleID); err != nil { return } else if !svc.prmSvc.CanReadRecord(module) { return errors.New("not allowed to access this record") @@ -129,7 +131,7 @@ func (svc *record) Create(in *types.Record) (record *types.Record, err error) { var module *types.Module err = svc.db.Transaction(func() (err error) { - if module, err = svc.moduleRepo.FindByID(in.ModuleID); err != nil { + if module, err = svc.moduleRepo.FindByID(in.NamespaceID, in.ModuleID); err != nil { return } else if !svc.prmSvc.CanCreateRecord(module) { return errors.New("not allowed to create records for this module") @@ -174,7 +176,7 @@ func (svc *record) Update(updated *types.Record) (record *types.Record, err erro return errors.New("not allowed to update this record") } - if module, err = svc.moduleRepo.FindByID(updated.ModuleID); err != nil { + if module, err = svc.moduleRepo.FindByID(updated.NamespaceID, updated.ModuleID); err != nil { return } diff --git a/compose/rest/module.go b/compose/rest/module.go index 63d966058..450b0fec5 100644 --- a/compose/rest/module.go +++ b/compose/rest/module.go @@ -11,29 +11,50 @@ import ( ) type ( + modulePayload struct { + *types.Module + + CanUpdateModule bool `json:"canUpdateModule"` + CanDeleteModule bool `json:"canDeleteModule"` + CanCreateRecord bool `json:"canCreateRecord"` + CanReadRecord bool `json:"canReadRecord"` + CanUpdateRecord bool `json:"canUpdateRecord"` + CanDeleteRecord bool `json:"canDeleteRecord"` + } + + moduleSetPayload struct { + Filter types.ModuleFilter `json:"filter"` + Set []*modulePayload `json:"set"` + } + Module struct { - module service.ModuleService - record service.RecordService + module service.ModuleService + record service.RecordService + permissions service.PermissionsService } ) func (Module) New() *Module { return &Module{ - module: service.DefaultModule, - record: service.DefaultRecord, + module: service.DefaultModule, + record: service.DefaultRecord, + permissions: service.DefaultPermissions, } } func (ctrl *Module) List(ctx context.Context, r *request.ModuleList) (interface{}, error) { - return ctrl.module.With(ctx).Find() + f := types.ModuleFilter{ + Query: r.Query, + PerPage: r.PerPage, + Page: r.Page, + } + + set, filter, err := ctrl.module.With(ctx).Find(f) + return ctrl.makeFilterPayload(ctx, set, filter, err) } func (ctrl *Module) Read(ctx context.Context, r *request.ModuleRead) (interface{}, error) { - return ctrl.module.With(ctx).FindByID(r.ModuleID) -} - -func (ctrl *Module) Delete(ctx context.Context, r *request.ModuleDelete) (interface{}, error) { - return resputil.OK(), ctrl.module.With(ctx).DeleteByID(r.ModuleID) + return ctrl.module.With(ctx).FindByID(r.NamespaceID, r.ModuleID) } func (ctrl *Module) Create(ctx context.Context, r *request.ModuleCreate) (interface{}, error) { @@ -46,11 +67,59 @@ func (ctrl *Module) Create(ctx context.Context, r *request.ModuleCreate) (interf } func (ctrl *Module) Update(ctx context.Context, r *request.ModuleUpdate) (interface{}, error) { - item := &types.Module{ - ID: r.ModuleID, - Name: r.Name, - Fields: r.Fields, - Meta: r.Meta, - } - return ctrl.module.With(ctx).Update(item) + var ( + m = &types.Module{} + err error + ) + + m.ID = r.ModuleID + m.Name = r.Name + m.Meta = r.Meta + m.NamespaceID = r.NamespaceID + m.UpdatedAt = r.UpdatedAt + + m, err = ctrl.module.With(ctx).Update(m) + return ctrl.makePayload(ctx, m, err) +} + +func (ctrl *Module) Delete(ctx context.Context, r *request.ModuleDelete) (interface{}, error) { + _, err := ctrl.module.With(ctx).FindByID(r.NamespaceID, r.ModuleID) + if err != nil { + return nil, err + } + + return resputil.OK(), ctrl.module.With(ctx).DeleteByID(r.NamespaceID, r.ModuleID) +} + +func (ctrl Module) makePayload(ctx context.Context, m *types.Module, err error) (*modulePayload, error) { + if err != nil || m == nil { + return nil, err + } + + perm := ctrl.permissions.With(ctx) + + return &modulePayload{ + Module: m, + + CanUpdateModule: perm.CanUpdateModule(m), + CanDeleteModule: perm.CanDeleteModule(m), + CanCreateRecord: perm.CanCreateRecord(m), + CanReadRecord: perm.CanReadRecord(m), + CanUpdateRecord: perm.CanUpdateRecord(m), + CanDeleteRecord: perm.CanDeleteRecord(m), + }, nil +} + +func (ctrl Module) makeFilterPayload(ctx context.Context, nn types.ModuleSet, f types.ModuleFilter, err error) (*moduleSetPayload, error) { + if err != nil { + return nil, err + } + + msp := &moduleSetPayload{Filter: f, Set: make([]*modulePayload, len(nn))} + + for i := range nn { + msp.Set[i], _ = ctrl.makePayload(ctx, nn[i], nil) + } + + return msp, nil } diff --git a/compose/rest/request/module.go b/compose/rest/request/module.go index 4d70c539d..fe51fa52b 100644 --- a/compose/rest/request/module.go +++ b/compose/rest/request/module.go @@ -28,6 +28,7 @@ import ( "github.com/crusttech/crust/compose/types" sqlxTypes "github.com/jmoiron/sqlx/types" + "time" ) var _ = chi.URLParam @@ -36,6 +37,8 @@ var _ = multipart.FileHeader{} // Module list request parameters type ModuleList struct { Query string + Page uint + PerPage uint NamespaceID uint64 `json:",string"` } @@ -74,6 +77,14 @@ func (mReq *ModuleList) Fill(r *http.Request) (err error) { mReq.Query = val } + if val, ok := get["page"]; ok { + + mReq.Page = parseUint(val) + } + if val, ok := get["perPage"]; ok { + + mReq.PerPage = parseUint(val) + } mReq.NamespaceID = parseUInt64(chi.URLParam(r, "namespaceID")) return err @@ -189,6 +200,7 @@ type ModuleUpdate struct { Name string Fields types.ModuleFieldSet Meta sqlxTypes.JSONText + UpdatedAt *time.Time } func NewModuleUpdate() *ModuleUpdate { @@ -234,6 +246,12 @@ func (mReq *ModuleUpdate) Fill(r *http.Request) (err error) { return err } } + if val, ok := post["updatedAt"]; ok { + + if mReq.UpdatedAt, err = parseISODatePtrWithErr(val); err != nil { + return err + } + } return err } diff --git a/compose/types/module.go b/compose/types/module.go index 06590f390..a6f119595 100644 --- a/compose/types/module.go +++ b/compose/types/module.go @@ -22,6 +22,15 @@ type ( UpdatedAt *time.Time `db:"updated_at" json:"updatedAt,omitempty"` DeletedAt *time.Time `db:"deleted_at" json:"deletedAt,omitempty"` } + + ModuleFilter struct { + NamespaceID uint64 `json:"namespaceID,string"` + Query string `json:"query"` + Page uint `json:"page"` + PerPage uint `json:"perPage"` + // Sort string `json:"sort"` + Count uint `json:"count"` + } ) // Resource returns a system resource ID for this type diff --git a/docs/compose/README.md b/docs/compose/README.md index f833825a7..f9e06862e 100644 --- a/docs/compose/README.md +++ b/docs/compose/README.md @@ -232,6 +232,8 @@ Compose module definitions | Parameter | Type | Method | Description | Default | Required? | | --------- | ---- | ------ | ----------- | ------- | --------- | | query | string | GET | Search query | N/A | NO | +| page | uint | GET | Page number (0 based) | N/A | NO | +| perPage | uint | GET | Returned items per page (default 50) | N/A | NO | | namespaceID | uint64 | PATH | Namespace ID | N/A | YES | ## Create module @@ -283,6 +285,7 @@ Compose module definitions | name | string | POST | Module Name | N/A | YES | | fields | types.ModuleFieldSet | POST | Fields JSON | N/A | YES | | meta | sqlxTypes.JSONText | POST | Module meta data | N/A | YES | +| updatedAt | *time.Time | POST | Last update (or creation) date | N/A | NO | ## Delete module @@ -432,7 +435,7 @@ Compose Notifications # Pages -Compose module pages +Compose pages | Method | Endpoint | Purpose | | ------ | -------- | ------- |