fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:40:01 +01:00
parent 49b86efecb
commit d367dddbbd
98 changed files with 3983 additions and 5063 deletions

View File

@@ -1,43 +0,0 @@
package auth
import (
"context"
"github.com/tech/sendico/pkg/db/template"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TaggableDB implements tag operations with permission checking
type TaggableDB[T model.PermissionBoundStorable] interface {
// AddTag adds a tag to an entity with permission checking
AddTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error
// RemoveTagd removes a tags from the collection using organizationRef with permission checking
RemoveTags(ctx context.Context, accountRef, organizationRef, tagRef primitive.ObjectID) error
// RemoveTag removes a tag from an entity with permission checking
RemoveTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error
// AddTags adds multiple tags to an entity with permission checking
AddTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error
// SetTags sets the tags for an entity with permission checking
SetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error
// RemoveAllTags removes all tags from an entity with permission checking
RemoveAllTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) error
// GetTags gets the tags for an entity with permission checking
GetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) ([]primitive.ObjectID, error)
// HasTag checks if an entity has a specific tag with permission checking
HasTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) (bool, error)
// FindByTag finds all entities that have a specific tag with permission checking
FindByTag(ctx context.Context, accountRef, tagRef primitive.ObjectID) ([]T, error)
// FindByTags finds all entities that have any of the specified tags with permission checking
FindByTags(ctx context.Context, accountRef primitive.ObjectID, tagRefs []primitive.ObjectID) ([]T, error)
}
// NewTaggableDBImp creates a new auth.TaggableDB instance
func NewTaggableDB[T model.PermissionBoundStorable](
dbImp *template.DBImp[T],
enforcer Enforcer,
createEmpty func() T,
getTaggable func(T) *model.Taggable,
) TaggableDB[T] {
return newTaggableDBImp(dbImp, enforcer, createEmpty, getTaggable)
}

View File

@@ -1,302 +0,0 @@
package auth
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/db/template"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// taggableDBImp implements tag operations with permission checking
type taggableDBImp[T model.PermissionBoundStorable] struct {
dbImp *template.DBImp[T]
logger mlogger.Logger
enforcer Enforcer
createEmpty func() T
getTaggable func(T) *model.Taggable
}
func newTaggableDBImp[T model.PermissionBoundStorable](
dbImp *template.DBImp[T],
enforcer Enforcer,
createEmpty func() T,
getTaggable func(T) *model.Taggable,
) TaggableDB[T] {
return &taggableDBImp[T]{
dbImp: dbImp,
logger: dbImp.Logger.Named("taggable"),
enforcer: enforcer,
createEmpty: createEmpty,
getTaggable: getTaggable,
}
}
func (db *taggableDBImp[T]) AddTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
return err
}
// Add the tag
patch := repository.Patch().AddToSet(repository.TagRefsField(), tagRef)
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
db.logger.Warn("Failed to add tag to object", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return err
}
db.logger.Debug("Successfully added tag to object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return nil
}
func (db *taggableDBImp[T]) removeTag(ctx context.Context, accountRef, targetRef, tagRef primitive.ObjectID, query builder.Query) error {
// Check permissions using enforceObject helper
if err := enforceObject(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, query); err != nil {
db.logger.Debug("Error enforcing permissions for removing tag", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("target_ref", targetRef), mzap.ObjRef("tag_ref", tagRef))
return err
}
// Remove the tag
patch := repository.Patch().Pull(repository.TagRefsField(), tagRef)
patched, err := db.dbImp.PatchMany(ctx, query, patch)
if err != nil {
db.logger.Warn("Failed to remove tag from object", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("target_ref", targetRef), mzap.ObjRef("tag_ref", tagRef))
return err
}
db.logger.Debug("Successfully removed tag from object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("target_ref", targetRef), mzap.ObjRef("tag_ref", tagRef), zap.Int("patched_count", patched))
return nil
}
func (db *taggableDBImp[T]) RemoveTags(ctx context.Context, accountRef, organizationRef, tagRef primitive.ObjectID) error {
return db.removeTag(ctx, accountRef, primitive.NilObjectID, tagRef, repository.OrgFilter(organizationRef))
}
func (db *taggableDBImp[T]) RemoveTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) error {
return db.removeTag(ctx, accountRef, objectRef, tagRef, repository.IDFilter(objectRef))
}
// AddTags adds multiple tags to an entity with permission checking
func (db *taggableDBImp[T]) AddTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
return err
}
// Add the tags one by one using $addToSet to avoid duplicates
for _, tagRef := range tagRefs {
patch := repository.Patch().AddToSet(repository.TagRefsField(), tagRef)
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
db.logger.Warn("Failed to add tag to object", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return err
}
}
db.logger.Debug("Successfully added tags to object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), zap.Int("tag_count", len(tagRefs)))
return nil
}
// SetTags sets the tags for an entity with permission checking
func (db *taggableDBImp[T]) SetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID, tagRefs []primitive.ObjectID) error {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
return err
}
// Set the tags
patch := repository.Patch().Set(repository.TagRefsField(), tagRefs)
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
db.logger.Warn("Failed to set tags for object", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef))
return err
}
db.logger.Debug("Successfully set tags for object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), zap.Int("tag_count", len(tagRefs)))
return nil
}
// RemoveAllTags removes all tags from an entity with permission checking
func (db *taggableDBImp[T]) RemoveAllTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) error {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
return err
}
// Remove all tags by setting to empty array
patch := repository.Patch().Set(repository.TagRefsField(), []primitive.ObjectID{})
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
db.logger.Warn("Failed to remove all tags from object", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef))
return err
}
db.logger.Debug("Successfully removed all tags from object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef))
return nil
}
// GetTags gets the tags for an entity with permission checking
func (db *taggableDBImp[T]) GetTags(ctx context.Context, accountRef, objectRef primitive.ObjectID) ([]primitive.ObjectID, error) {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil {
return nil, err
}
// Get the object and extract tags
obj := db.createEmpty()
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
db.logger.Warn("Failed to get object for retrieving tags", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef))
return nil, err
}
// Get the tags
taggable := db.getTaggable(obj)
db.logger.Debug("Successfully retrieved tags for object", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), zap.Int("tag_count", len(taggable.TagRefs)))
return taggable.TagRefs, nil
}
// HasTag checks if an entity has a specific tag with permission checking
func (db *taggableDBImp[T]) HasTag(ctx context.Context, accountRef, objectRef, tagRef primitive.ObjectID) (bool, error) {
// Check permissions using enforceObject helper
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil {
return false, err
}
// Get the object and check if the tag exists
obj := db.createEmpty()
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
db.logger.Warn("Failed to get object for checking tag", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return false, err
}
// Check if the tag exists
taggable := db.getTaggable(obj)
for _, existingTag := range taggable.TagRefs {
if existingTag == tagRef {
db.logger.Debug("Object has tag", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return true, nil
}
}
db.logger.Debug("Object does not have tag", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("object_ref", objectRef), mzap.ObjRef("tag_ref", tagRef))
return false, nil
}
// FindByTag finds all entities that have a specific tag with permission checking
func (db *taggableDBImp[T]) FindByTag(ctx context.Context, accountRef, tagRef primitive.ObjectID) ([]T, error) {
// Create filter to find objects with the tag
filter := repository.Filter(model.TagRefsField, tagRef)
// Get all objects with the tag using ListPermissionBound
objects, err := db.dbImp.ListPermissionBound(ctx, filter)
if err != nil {
db.logger.Warn("Failed to get objects with tag", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("tag_ref", tagRef))
return nil, err
}
// Check permissions for all objects using EnforceBatch
db.logger.Debug("Checking permissions for objects with tag", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("tag_ref", tagRef), zap.Int("object_count", len(objects)))
permissions, err := db.enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
if err != nil {
db.logger.Warn("Failed to check permissions for objects with tag", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("tag_ref", tagRef), zap.Int("object_count", len(objects)))
return nil, merrors.Internal("failed to check permissions for objects with tag")
}
// Filter objects based on permissions and decode them
var results []T
for _, obj := range objects {
objID := *obj.GetID()
if hasPermission, exists := permissions[objID]; exists && hasPermission {
// Decode the object
decodedObj := db.createEmpty()
if err := db.dbImp.Get(ctx, objID, decodedObj); err != nil {
db.logger.Warn("Failed to decode object with tag", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objID), mzap.ObjRef("tag_ref", tagRef))
continue
}
results = append(results, decodedObj)
}
}
db.logger.Debug("Successfully found objects with tag", mzap.ObjRef("account_ref", accountRef),
mzap.ObjRef("tag_ref", tagRef), zap.Int("total_objects", len(objects)), zap.Int("accessible_objects", len(results)))
return results, nil
}
// FindByTags finds all entities that have any of the specified tags with permission checking
func (db *taggableDBImp[T]) FindByTags(ctx context.Context, accountRef primitive.ObjectID, tagRefs []primitive.ObjectID) ([]T, error) {
if len(tagRefs) == 0 {
return []T{}, nil
}
// Convert []primitive.ObjectID to []any for the In method
values := make([]any, len(tagRefs))
for i, tagRef := range tagRefs {
values[i] = tagRef
}
// Create filter to find objects with any of the tags
filter := repository.Query().In(repository.TagRefsField(), values...)
// Get all objects with any of the tags using ListPermissionBound
objects, err := db.dbImp.ListPermissionBound(ctx, filter)
if err != nil {
db.logger.Warn("Failed to get objects with tags", zap.Error(err),
mzap.ObjRef("account_ref", accountRef))
return nil, err
}
// Check permissions for all objects using EnforceBatch
db.logger.Debug("Checking permissions for objects with tags", mzap.ObjRef("account_ref", accountRef),
zap.Int("object_count", len(objects)), zap.Int("tag_count", len(tagRefs)))
permissions, err := db.enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
if err != nil {
db.logger.Warn("Failed to check permissions for objects with tags", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), zap.Int("object_count", len(objects)))
return nil, merrors.Internal("failed to check permissions for objects with tags")
}
// Filter objects based on permissions and decode them
var results []T
for _, obj := range objects {
objID := *obj.GetID()
if hasPermission, exists := permissions[objID]; exists && hasPermission {
// Decode the object
decodedObj := db.createEmpty()
if err := db.dbImp.Get(ctx, objID, decodedObj); err != nil {
db.logger.Warn("Failed to decode object with tags", zap.Error(err),
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("object_ref", objID))
continue
}
results = append(results, decodedObj)
}
}
db.logger.Debug("Successfully found objects with tags", mzap.ObjRef("account_ref", accountRef),
zap.Int("total_objects", len(objects)), zap.Int("accessible_objects", len(results)), zap.Int("tag_count", len(tagRefs)))
return results, nil
}

View File

@@ -1,69 +0,0 @@
package indexable
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// Example usage of the generic IndexableDB with different types
// Example 1: Using with Project
func ExampleProjectIndexableDB(repo repository.Repository, logger mlogger.Logger, organizationRef primitive.ObjectID) {
// Define helper functions for Project
createEmpty := func() *model.Project {
return &model.Project{}
}
getIndexable := func(p *model.Project) *model.Indexable {
return &p.Indexable
}
// Create generic IndexableDB for Project
projectDB := NewIndexableDB(repo, logger, createEmpty, getIndexable)
// Use with organization filter
orgFilter := repository.OrgFilter(organizationRef)
projectDB.Reorder(context.Background(), primitive.NewObjectID(), 2, orgFilter)
}
// Example 3: Using with Task
func ExampleTaskIndexableDB(repo repository.Repository, logger mlogger.Logger, statusRef primitive.ObjectID) {
// Define helper functions for Task
createEmpty := func() *model.Task {
return &model.Task{}
}
getIndexable := func(t *model.Task) *model.Indexable {
return &t.Indexable
}
// Create generic IndexableDB for Task
taskDB := NewIndexableDB(repo, logger, createEmpty, getIndexable)
// Use with status filter
statusFilter := repository.Query().Comparison(repository.Field("statusRef"), builder.Eq, statusRef)
taskDB.Reorder(context.Background(), primitive.NewObjectID(), 3, statusFilter)
}
// Example 5: Using without any filter (global reordering)
func ExampleGlobalIndexableDB(repo repository.Repository, logger mlogger.Logger) {
// Define helper functions for any Indexable type
createEmpty := func() *model.Project {
return &model.Project{}
}
getIndexable := func(p *model.Project) *model.Indexable {
return &p.Indexable
}
// Create generic IndexableDB without filters
globalDB := NewIndexableDB(repo, logger, createEmpty, getIndexable)
// Use without any filter - reorders all items globally
globalDB.Reorder(context.Background(), primitive.NewObjectID(), 5, repository.Query())
}

View File

@@ -1,314 +0,0 @@
//go:build integration
// +build integration
package indexable
import (
"context"
"testing"
"time"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"go.uber.org/zap"
)
func setupTestDB(t *testing.T) (repository.Repository, func()) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mongoContainer, err := mongodb.Run(ctx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
)
require.NoError(t, err, "failed to start MongoDB container")
mongoURI, err := mongoContainer.ConnectionString(ctx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(ctx, clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
db := client.Database("testdb")
repo := repository.CreateMongoRepository(db, "projects")
cleanup := func() {
disconnect(ctx, t, client)
terminate(ctx, t, mongoContainer)
}
return repo, cleanup
}
func disconnect(ctx context.Context, t *testing.T, client *mongo.Client) {
if err := client.Disconnect(ctx); err != nil {
t.Logf("failed to disconnect from MongoDB: %v", err)
}
}
func terminate(ctx context.Context, t *testing.T, container testcontainers.Container) {
if err := container.Terminate(ctx); err != nil {
t.Logf("failed to terminate MongoDB container: %v", err)
}
}
func TestIndexableDB_Reorder(t *testing.T) {
repo, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
organizationRef := primitive.NewObjectID()
logger := zap.NewNop()
// Create test projects with different indices
projects := []*model.Project{
{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: organizationRef,
},
},
Describable: model.Describable{Name: "Project A"},
Indexable: model.Indexable{Index: 0},
Mnemonic: "A",
State: model.ProjectStateActive,
},
},
{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: organizationRef,
},
},
Describable: model.Describable{Name: "Project B"},
Indexable: model.Indexable{Index: 1},
Mnemonic: "B",
State: model.ProjectStateActive,
},
},
{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: organizationRef,
},
},
Describable: model.Describable{Name: "Project C"},
Indexable: model.Indexable{Index: 2},
Mnemonic: "C",
State: model.ProjectStateActive,
},
},
{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: organizationRef,
},
},
Describable: model.Describable{Name: "Project D"},
Indexable: model.Indexable{Index: 3},
Mnemonic: "D",
State: model.ProjectStateActive,
},
},
}
// Insert projects into database
for _, project := range projects {
project.ID = primitive.NewObjectID()
err := repo.Insert(ctx, project, nil)
require.NoError(t, err)
}
// Create helper functions for Project type
createEmpty := func() *model.Project {
return &model.Project{}
}
getIndexable := func(p *model.Project) *model.Indexable {
return &p.Indexable
}
indexableDB := NewIndexableDB(repo, logger, createEmpty, getIndexable)
t.Run("Reorder_NoChange", func(t *testing.T) {
// Test reordering to the same position (should be no-op)
err := indexableDB.Reorder(ctx, projects[1].ID, 1, repository.Query())
require.NoError(t, err)
// Verify indices haven't changed
var result model.Project
err = repo.Get(ctx, projects[0].ID, &result)
require.NoError(t, err)
assert.Equal(t, 0, result.Index)
err = repo.Get(ctx, projects[1].ID, &result)
require.NoError(t, err)
assert.Equal(t, 1, result.Index)
})
t.Run("Reorder_MoveDown", func(t *testing.T) {
// Move Project A (index 0) to index 2
err := indexableDB.Reorder(ctx, projects[0].ID, 2, repository.Query())
require.NoError(t, err)
// Verify the reordering:
// Project A should now be at index 2
// Project B should be at index 0
// Project C should be at index 1
// Project D should remain at index 3
var result model.Project
// Check Project A (moved to index 2)
err = repo.Get(ctx, projects[0].ID, &result)
require.NoError(t, err)
assert.Equal(t, 2, result.Index)
// Check Project B (shifted to index 0)
err = repo.Get(ctx, projects[1].ID, &result)
require.NoError(t, err)
assert.Equal(t, 0, result.Index)
// Check Project C (shifted to index 1)
err = repo.Get(ctx, projects[2].ID, &result)
require.NoError(t, err)
assert.Equal(t, 1, result.Index)
// Check Project D (unchanged)
err = repo.Get(ctx, projects[3].ID, &result)
require.NoError(t, err)
assert.Equal(t, 3, result.Index)
})
t.Run("Reorder_MoveUp", func(t *testing.T) {
// Reset indices for this test
for i, project := range projects {
project.Index = i
err := repo.Update(ctx, project)
require.NoError(t, err)
}
// Move Project C (index 2) to index 0
err := indexableDB.Reorder(ctx, projects[2].ID, 0, repository.Query())
require.NoError(t, err)
// Verify the reordering:
// Project C should now be at index 0
// Project A should be at index 1
// Project B should be at index 2
// Project D should remain at index 3
var result model.Project
// Check Project C (moved to index 0)
err = repo.Get(ctx, projects[2].ID, &result)
require.NoError(t, err)
assert.Equal(t, 0, result.Index)
// Check Project A (shifted to index 1)
err = repo.Get(ctx, projects[0].ID, &result)
require.NoError(t, err)
assert.Equal(t, 1, result.Index)
// Check Project B (shifted to index 2)
err = repo.Get(ctx, projects[1].ID, &result)
require.NoError(t, err)
assert.Equal(t, 2, result.Index)
// Check Project D (unchanged)
err = repo.Get(ctx, projects[3].ID, &result)
require.NoError(t, err)
assert.Equal(t, 3, result.Index)
})
t.Run("Reorder_WithFilter", func(t *testing.T) {
// Reset indices for this test
for i, project := range projects {
project.Index = i
err := repo.Update(ctx, project)
require.NoError(t, err)
}
// Test reordering with organization filter
orgFilter := repository.OrgFilter(organizationRef)
err := indexableDB.Reorder(ctx, projects[0].ID, 2, orgFilter)
require.NoError(t, err)
// Verify the reordering worked with filter
var result model.Project
err = repo.Get(ctx, projects[0].ID, &result)
require.NoError(t, err)
assert.Equal(t, 2, result.Index)
})
}
func TestIndexableDB_EdgeCases(t *testing.T) {
repo, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
organizationRef := primitive.NewObjectID()
logger := zap.NewNop()
// Create a single project for edge case testing
project := &model.Project{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: organizationRef,
},
},
Describable: model.Describable{Name: "Test Project"},
Indexable: model.Indexable{Index: 0},
Mnemonic: "TEST",
State: model.ProjectStateActive,
},
}
project.ID = primitive.NewObjectID()
err := repo.Insert(ctx, project, nil)
require.NoError(t, err)
// Create helper functions for Project type
createEmpty := func() *model.Project {
return &model.Project{}
}
getIndexable := func(p *model.Project) *model.Indexable {
return &p.Indexable
}
indexableDB := NewIndexableDB(repo, logger, createEmpty, getIndexable)
t.Run("Reorder_SingleItem", func(t *testing.T) {
// Test reordering a single item (should work but have no effect)
err := indexableDB.Reorder(ctx, project.ID, 0, repository.Query())
require.NoError(t, err)
var result model.Project
err = repo.Get(ctx, project.ID, &result)
require.NoError(t, err)
assert.Equal(t, 0, result.Index)
})
t.Run("Reorder_InvalidObjectID", func(t *testing.T) {
// Test reordering with an invalid object ID
invalidID := primitive.NewObjectID()
err := indexableDB.Reorder(ctx, invalidID, 1, repository.Query())
require.Error(t, err) // Should fail because object doesn't exist
})
}

View File

@@ -116,10 +116,6 @@ func IndexFilter(index int) builder.Query {
return Query().Filter(IndexField(), index)
}
func TagRefsField() builder.Field {
return Field(model.TagRefsField)
}
func IndexOpFilter(index int, operation builder.MongoOperation) builder.Query {
return Query().Comparison(IndexField(), operation, index)
}

View File

@@ -1,16 +0,0 @@
package tag
import (
"context"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type DB interface {
auth.ProtectedDB[*model.Tag]
List(ctx context.Context, accountRef, organizationRef, parentRef primitive.ObjectID, cursor *model.ViewCursor) ([]model.Tag, error)
All(ctx context.Context, organizationRef primitive.ObjectID, limit, offset *int64) ([]model.Tag, error)
SetArchived(ctx context.Context, accountRef, organizationRef, tagRef primitive.ObjectID, archived, cascade bool) error
}

View File

@@ -1,8 +0,0 @@
package model
// Attachment represents metadata for an attachment in a comment.
type Attachment struct {
Describable `bson:",inline" json:",inline"`
Type string `bson:"type" json:"type"` // Type of attachment (e.g., "image", "file", "rich_text")
URL string `bson:"url" json:"url"` // URL of the attachment (e.g., an image or file location)
}

View File

@@ -1,15 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
type Automation struct {
storable.Base `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
}
func (*Automation) Collection() string {
return mservice.Automations
}

View File

@@ -1,35 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type CommentBase struct {
PermissionBound `bson:",inline" json:",inline"`
AuthorRef primitive.ObjectID `bson:"authorRef" json:"authorRef"` // Reference to the author (user) of the comment
TaskRef primitive.ObjectID `bson:"taskRef" json:"taskRef"` // Reference to the task
Attachments []Attachment `bson:"attachments" json:"attachments"` // List of attachments
Reactions []Reaction `bson:"reactions" json:"reactions"` // List of attachments
Content string `bson:"content" json:"content"` // Text content
IsFormatted bool `bson:"isFormatted" json:"isFormatted"` // Flag for formatted content
}
func (*CommentBase) Collection() string {
return mservice.Comments
}
// Comment represents a comment attached to a task.
type Comment struct {
CommentBase `bson:",inline" json:",inline"`
}
// NewTaskComment creates a new instance of TaskComment.
func NewComment(taskRef, authorRef primitive.ObjectID, content string) *Comment {
return &Comment{
CommentBase: CommentBase{
AuthorRef: authorRef,
Content: content,
},
}
}

View File

@@ -1,8 +0,0 @@
package model
import "go.mongodb.org/mongo-driver/bson/primitive"
type CommentPreview struct {
TaskRef primitive.ObjectID `json:"taskRef" bson:"taskRef"`
CommentsCount int `json:"commentsCount" bson:"commentsCount"`
}

View File

@@ -1,13 +0,0 @@
package model
type Custimizable interface {
GetProperties() []Value
}
type CustomozableBase struct {
Properties []Value `bson:"properties" json:"properties"`
}
func (c *CustomozableBase) GetProperties() []Value {
return c.Properties
}

View File

@@ -1,31 +0,0 @@
package model
import "go.mongodb.org/mongo-driver/bson/primitive"
type TagFilterMode string
const (
TagFilterModeNone TagFilterMode = "none"
TagFilterModePresent TagFilterMode = "present"
TagFilterModeMissing TagFilterMode = "missing"
TagFilterModeIncludeAny TagFilterMode = "includeAny"
TagFilterModeIncludeAll TagFilterMode = "includeAll"
TagFilterModeExcludeAny TagFilterMode = "excludeAny"
)
type TagFilter struct {
Mode *TagFilterMode `bson:"mode,omitempty" json:"mode,omitempty"`
TagRefs []primitive.ObjectID `bson:"tagRefs,omitempty" json:"tagRefs,omitempty"`
}
type ObjectsFilter struct {
Query *string `bson:"query,omitempty" json:"query,omitempty"`
CaseSensitive *bool `bson:"caseSensitive,omitempty" json:"caseSensitive,omitempty"`
TagFilter *TagFilter `bson:"tagFilter,omitempty" json:"tagFilter,omitempty"`
Sort *ObjectsSort `bson:"sort,omitempty" json:"sort,omitempty"`
}
type ObjectsSort struct {
Field string `bson:"field" json:"field"`
Direction string `bson:"direction" json:"direction"`
}

View File

@@ -1,30 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// InvoiceStatus represents the status of an invoice.
type InvoiceStatus string
const (
InvoiceStatusPending InvoiceStatus = "pending" // Invoice is created but not paid
InvoiceStatusPaid InvoiceStatus = "paid" // Invoice has been fully paid
InvoiceStatusCancelled InvoiceStatus = "cancelled" // Invoice has been cancelled
)
type Invoice struct {
storable.Base `bson:",inline" json:",inline"`
Note string `bson:"note" json:"note"`
Link *Link `bson:"link,omitempty" json:"link,omitempty"`
OrganizationRef primitive.ObjectID `bson:"organizationRef" json:"organizationRef"`
RecipientRef primitive.ObjectID `bson:"recipientRef" json:"recipientRef"`
Amount Amount `bson:"amount" json:"amount"`
Status InvoiceStatus `bson:"status" json:"status"` // Invoice status
}
func (*Invoice) Collection() string {
return mservice.Invoices
}

View File

@@ -1,32 +0,0 @@
package model
import (
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ScopeMode string
const (
ScopeAll ScopeMode = "all" // apply to all of that type
ScopeOnly ScopeMode = "only" // only listed IDs
ScopeAllExcept ScopeMode = "all_except" // all minus listed IDs
)
type TargetScope struct {
ObjectRefs `bson:"target" json:"target"`
Mode ScopeMode `bson:"mode" json:"mode"`
}
type PropertyInstance struct {
Global bool `bson:"global" json:"global"` // Property has single value for all property users
Required bool `bson:"required" json:"required"` // Presence requirement (works for One and Many).
UniqueAcrossEntities bool `bson:"uniqueAcrossEntities" json:"uniqueAcrossEntities"` // Uniqueness across ENTITIES (DB-level concern; enforce in assignments collection).
PropertySchemaRef primitive.ObjectID `bson:"propertySchemaRef" json:"propertySchemaRef"`
}
type PropertiesBinding struct {
PermissionBound `bson:"inline" json:"inline"`
Scope TargetScope `bson:"scope" json:"scope"`
Bindings []PropertyInstance `bson:"bindings" json:"bindings"`
ApplicableScopes []TargetScope `bson:"applicableScopes" json:"applicableScopes"`
}

View File

@@ -1,24 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ProjectFilterBase struct {
ObjectsFilter `bson:",inline" json:",inline"`
Archived *bool `bson:"isArchived,omitempty" json:"isArchived,omitempty"`
AssigneeRefs []primitive.ObjectID `bson:"assigneeRefs,omitempty" json:"assigneeRefs,omitempty"`
ReporterRefs []primitive.ObjectID `bson:"reporterRefs,omitempty" json:"reporterRefs,omitempty"`
EmployeeRefs []primitive.ObjectID `bson:"employeeRefs,omitempty" json:"employeeRefs,omitempty"`
}
type ProjectFilter struct {
AccountBoundBase `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
ProjectFilterBase `bson:",inline" json:",inline"`
}
func (*ProjectFilter) Collection() string {
return mservice.FilterProjects
}

View File

@@ -1,24 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/mservice"
)
type Priority struct {
PermissionBound `bson:",inline" json:",inline"`
Colorable `bson:",inline" json:",inline"`
}
func (*Priority) Collection() string {
return mservice.Priorities
}
type PriorityGroup struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Priorities []IndexableRef `bson:"priorities" json:"priorities"`
}
func (*PriorityGroup) Collection() string {
return mservice.PriorityGroups
}

View File

@@ -1,61 +0,0 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ProjectState string
const (
ProjectStateActive ProjectState = "active"
ProjectStateHold ProjectState = "hold"
ProjectStateBlocked ProjectState = "blocked"
)
type ProjectBase struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Indexable `bson:",inline" json:",inline"`
Taggable `bson:",inline" json:",inline"`
LogoURL *string `bson:"logoUrl" json:"logoUrl"`
Mnemonic string `bson:"mnemonic" json:"mnemonic"`
State ProjectState `bson:"state" json:"state"`
PriorityGroupRef primitive.ObjectID `bson:"priorityGroupRef" json:"priorityGroupRef"`
StatusGroupRef primitive.ObjectID `bson:"statusGroupRef" json:"statusGroupRef"`
}
func (*ProjectBase) Collection() string {
return mservice.Projects
}
type Project struct {
ProjectBase `bson:",inline" json:",inline"`
NextTaskNumber int `bson:"nextTaskNumber" json:"nextTaskNumber"`
}
type ProjectOverallStats struct {
TotalTasks int `json:"totalTasks" bson:"totalTasks"`
OpenTasks int `json:"openTasks" bson:"openTasks"`
OverDue int `json:"overDue" bson:"overDue"`
NextDeadline *time.Time `json:"nextDeadline,omitempty" bson:"nextDeadline,omitempty"`
}
// ProjectPersonallStatsD represents personal task statistics for a project.
type ProjectPersonallStatsD struct {
FreeTasks int `json:"freeTasks" bson:"freeTasks"`
CompleteTasks int `json:"completeTasks" bson:"completeTasks"`
MyTasks int `json:"myTasks" bson:"myTasks"`
OverDue int `json:"overDue" bson:"overDue"`
NextDeadline *time.Time `json:"nextDeadline,omitempty" bson:"nextDeadline,omitempty"`
}
// ProjectPreview represents a preview of project information.
type ProjectPreview struct {
ProjectRef primitive.ObjectID `json:"projectRef" bson:"projectRef"`
Team []primitive.ObjectID `json:"team" bson:"team"`
Overall ProjectOverallStats `json:"overall" bson:"overall"`
Personal ProjectPersonallStatsD `json:"personal" bson:"personal"`
}

View File

@@ -1,671 +0,0 @@
package model
import (
"fmt"
"math/big"
"regexp"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mservice"
)
// ----------------------------
// Core discriminant/type
// ----------------------------
type PropertyType = string
const (
PTDateTime PropertyType = "date_time"
PTInteger PropertyType = "integer"
PTFloat PropertyType = "float"
PTMonetary PropertyType = "monetary"
PTReference PropertyType = "reference"
PTString PropertyType = "string"
PTColor PropertyType = "color"
PTObject PropertyType = "object"
)
// Value keys for SettingsT maps
const (
VKString = "string"
VKStrings = "strings"
VKColor = "color"
VKColors = "colors"
VKInteger = "integer"
VKIntegers = "integers"
VKFloat = "float"
VKFloats = "floats"
VKDateTime = "date_time"
VKDateTimes = "date_times"
VKMonetary = "monetary"
VKMonetaries = "monetaries"
VKReference = "reference"
VKReferences = "references"
VKObject = "object"
VKObjects = "objects"
)
// Money struct field keys
const (
MKAmount = "amount"
MKCurrency = "currency"
)
// ----------------------------
// Small value types (runtime values)
// ----------------------------
// ----------------------------
// Type-specific PROPS (schema/constraints)
// ----------------------------
type IntegerProps struct {
Default *int64 `bson:"default,omitempty" json:"default,omitempty"`
Min *int64 `bson:"min,omitempty" json:"min,omitempty"`
Max *int64 `bson:"max,omitempty" json:"max,omitempty"`
Allowed []int64 `bson:"allowed,omitempty" json:"allowed,omitempty"`
}
type FloatProps struct {
Default *float64 `bson:"default,omitempty" json:"default,omitempty"`
Min *float64 `bson:"min,omitempty" json:"min,omitempty"`
Max *float64 `bson:"max,omitempty" json:"max,omitempty"`
}
type StringProps struct {
Default *string `bson:"default,omitempty" json:"default,omitempty"`
Allowed []string `bson:"allowed,omitempty" json:"allowed,omitempty"`
Pattern string `bson:"pattern" json:"pattern"` // Go RE2 syntax
MinLen *int `bson:"minLen,omitempty" json:"minLen,omitempty"`
MaxLen *int `bson:"maxLen,omitempty" json:"maxLen,omitempty"`
}
type DateTimeProps struct {
Default *time.Time `bson:"default,omitempty" json:"default,omitempty"` // store UTC
Earliest *time.Time `bson:"earliest,omitempty" json:"earliest,omitempty"`
Latest *time.Time `bson:"latest,omitempty" json:"latest,omitempty"`
}
type ColorProps struct {
AllowAlpha bool `bson:"allowAlpha,omitempty" json:"allowAlpha,omitempty"`
AllowedPalette []string `bson:"allowedPalette,omitempty" json:"allowedPalette,omitempty"` // optional whitelist of hex colors
Default string `bson:"default,omitempty" json:"default,omitempty"`
}
type ObjectProps struct {
Properties []PropertySchema `bson:"properties,omitempty" json:"properties,omitempty"`
}
// Currency policy for monetary props.
type CurrencyMode string
const (
CurrencyFixed CurrencyMode = "fixed" // force one currency (FixedCurrency)
CurrencyOrg CurrencyMode = "org" // force org default currency at runtime
CurrencyFree CurrencyMode = "free" // allow any (optionally restricted by AllowedCurrencies)
)
type MonetaryProps struct {
CurrencyMode CurrencyMode `bson:"currencyMode" json:"currencyMode"`
FixedCurrency Currency `bson:"fixedCurrency" json:"fixedCurrency"` // required if fixed
AllowedCurrencies []Currency `bson:"allowedCurrencies" json:"allowedCurrencies"` // for free mode
// Optional precision/rules; if nil, infer elsewhere by ISO minor units.
Scale *int `bson:"scale,omitempty" json:"scale,omitempty"` // allowed decimal places
Rounding *int `bson:"rounding,omitempty" json:"rounding,omitempty"` // app-specific; not enforced here
Default *Money `bson:"default,omitempty" json:"default,omitempty"`
Min *Money `bson:"min,omitempty" json:"min,omitempty"`
Max *Money `bson:"max,omitempty" json:"max,omitempty"`
}
type ReferenceProps struct {
Target mservice.Type `bson:"target" json:"target"` // e.g. "accounts"
AllowedIDs []primitive.ObjectID `bson:"allowedIds,omitempty" json:"allowedIds,omitempty"` // optional whitelist
Default *primitive.ObjectID `bson:"default,omitempty" json:"default,omitempty"` // optional default VALUE
}
// ----------------------------
// UI hints (optional)
// ----------------------------
type UIHints struct {
Placeholder string `bson:"placeholder" json:"placeholder"`
Unit string `bson:"unit" json:"unit"` // "kg", "cm", "€", etc.
HiddenInList bool `bson:"hiddenInList" json:"hiddenInList"`
Filterable bool `bson:"filterable" json:"filterable"`
}
// ----------------------------
// Multiplicity (generic, applies to any type)
// ----------------------------
type Cardinality string
const (
One Cardinality = "one" // single value
Many Cardinality = "many" // array of values
)
type Multiplicity struct {
Mode Cardinality `bson:"mode" json:"mode"` // default "one"
MinItems *int `bson:"minItems,omitempty" json:"minItems,omitempty"` // only when Mode=Many
MaxItems *int `bson:"maxItems,omitempty" json:"maxItems,omitempty"` // only when Mode=Many
// Distinct within one entity's list value (meaningful for Mode=Many).
Distinct bool `bson:"distinct" json:"distinct"`
}
// ----------------------------
// Property envelope
// ----------------------------
type PropertySchema struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
// customer permission refernece
ValuePermissionRef *primitive.ObjectID `bson:"valuePermissionRef,omitempty" json:"valuePermissionRef,omitempty"`
// Stable machine key; unique within (organizatoinRef, type, key)
Key string `bson:"key" json:"key"`
Type PropertyType `bson:"type" json:"type"`
// Lifecycle/UX
System bool `bson:"system" json:"system"`
UI *UIHints `bson:"ui,omitempty" json:"ui,omitempty"`
// Multiplicity controls (cross-type).
Multiplicity Multiplicity `bson:"multiplicity" json:"multiplicity"`
// Discriminated payload; a BSON subdocument shaped per Type.
Props any `bson:"props" json:"props"`
}
func (*PropertySchema) Collection() string { return mservice.PropertySchemas }
// ----------------------------
// Typed accessors for Props
// ----------------------------
func invalidType(expected, actual PropertyType) error {
return merrors.InvalidDataType(fmt.Sprintf("expected type is %s while actual type is %s", expected, actual))
}
// asTypedProps is a generic function that handles type checking and casting for all property types
func asTypedProps[T any](p *PropertySchema, expectedType PropertyType) (T, error) {
var out T
if p.Type != expectedType {
return out, invalidType(expectedType, p.Type)
}
// Props is stored directly as the correct type, so we can cast it
if props, ok := p.Props.(T); ok {
return props, nil
}
return out, merrors.InvalidArgument("invalid props type")
}
// Type-specific accessor functions using the generic template
func (p *PropertySchema) AsInteger() (IntegerProps, error) {
return asTypedProps[IntegerProps](p, PTInteger)
}
func (p *PropertySchema) AsFloat() (FloatProps, error) {
return asTypedProps[FloatProps](p, PTFloat)
}
func (p *PropertySchema) AsString() (StringProps, error) {
return asTypedProps[StringProps](p, PTString)
}
func (p *PropertySchema) AsDateTime() (DateTimeProps, error) {
return asTypedProps[DateTimeProps](p, PTDateTime)
}
func (p *PropertySchema) AsMonetary() (MonetaryProps, error) {
return asTypedProps[MonetaryProps](p, PTMonetary)
}
func (p *PropertySchema) AsReference() (ReferenceProps, error) {
return asTypedProps[ReferenceProps](p, PTReference)
}
func (p *PropertySchema) AsColor() (ColorProps, error) {
return asTypedProps[ColorProps](p, PTColor)
}
func (p *PropertySchema) AsObject() (ObjectProps, error) {
return asTypedProps[ObjectProps](p, PTObject)
}
// ----------------------------
// Validation helpers (generic)
// ----------------------------
func validateMultiplicity(count int, required bool, m Multiplicity) error {
mode := m.Mode
if mode == "" {
mode = One
}
switch mode {
case One:
if count > 1 {
return merrors.DataConflict("multiple values not allowed")
}
if required && count == 0 {
return merrors.DataConflict("value required")
}
case Many:
min := 0
if m.MinItems != nil {
min = *m.MinItems
} else if required {
min = 1
}
if count < min {
return merrors.DataConflict(fmt.Sprintf("minimum %d items", min))
}
if m.MaxItems != nil && count > *m.MaxItems {
return merrors.DataConflict(fmt.Sprintf("maximum %d items", *m.MaxItems))
}
default:
return merrors.InvalidArgument(fmt.Sprintf("unknown cardinality: %q", mode))
}
return nil
}
func ensureDistinct[T comparable](vals []T, distinct bool) error {
if !distinct || len(vals) < 2 {
return nil
}
seen := make(map[T]struct{}, len(vals))
for _, v := range vals {
if _, ok := seen[v]; ok {
return merrors.DataConflict("duplicate items not allowed")
}
seen[v] = struct{}{}
}
return nil
}
func ensureDistinctByKey[T any, K comparable](vals []T, key func(T) K, distinct bool) error {
if !distinct || len(vals) < 2 {
return nil
}
seen := make(map[K]struct{}, len(vals))
for _, v := range vals {
k := key(v)
if _, ok := seen[k]; ok {
return merrors.DataConflict("duplicate items not allowed")
}
seen[k] = struct{}{}
}
return nil
}
// ----------------------------
// Type validators
// ----------------------------
func (p PropertySchema) ValidateStrings(vals []string) error {
if p.Type != PTString {
return invalidType(PTString, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsString()
if err != nil {
return err
}
var re *regexp.Regexp
if props.Pattern != "" {
rx, rxErr := regexp.Compile(props.Pattern)
if rxErr != nil {
return merrors.InvalidArgument(fmt.Sprintf("invalid pattern: %v", rxErr))
}
re = rx
}
allow := map[string]struct{}{}
if len(props.Allowed) > 0 {
for _, a := range props.Allowed {
allow[a] = struct{}{}
}
}
for _, v := range vals {
if len(allow) > 0 {
if _, ok := allow[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("value %q not allowed", v))
}
}
if props.MinLen != nil && len(v) < *props.MinLen {
return merrors.DataConflict(fmt.Sprintf("value too short (min %d)", *props.MinLen))
}
if props.MaxLen != nil && len(v) > *props.MaxLen {
return merrors.DataConflict(fmt.Sprintf("value too long (max %d)", *props.MaxLen))
}
if re != nil && !re.MatchString(v) {
return merrors.DataConflict(fmt.Sprintf("value %q does not match pattern", v))
}
}
return nil
}
func (p PropertySchema) ValidateColors(vals []string) error {
if p.Type != PTColor {
return invalidType(PTColor, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
_, err := p.AsColor()
if err != nil {
return err
}
// For now, we can use the same validation as strings
// In the future, we might want to add color-specific validation
return nil
}
func (p PropertySchema) ValidateIntegers(vals []int64) error {
if p.Type != PTInteger {
return invalidType(PTInteger, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsInteger()
if err != nil {
return err
}
allow := map[int64]struct{}{}
if len(props.Allowed) > 0 {
for _, a := range props.Allowed {
allow[a] = struct{}{}
}
}
for _, v := range vals {
if len(allow) > 0 {
if _, ok := allow[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("value %d not allowed", v))
}
}
if props.Min != nil && v < *props.Min {
return merrors.DataConflict(fmt.Sprintf("value %d below min %d", v, *props.Min))
}
if props.Max != nil && v > *props.Max {
return merrors.DataConflict(fmt.Sprintf("value %d above max %d", v, *props.Max))
}
}
return nil
}
func (p PropertySchema) ValidateFloats(vals []float64) error {
if p.Type != PTFloat {
return invalidType(PTFloat, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
if err := ensureDistinct(vals, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsFloat()
if err != nil {
return err
}
for _, v := range vals {
if props.Min != nil && v < *props.Min {
return merrors.DataConflict(fmt.Sprintf("value %g below min %g", v, *props.Min))
}
if props.Max != nil && v > *props.Max {
return merrors.DataConflict(fmt.Sprintf("value %g above max %g", v, *props.Max))
}
}
return nil
}
func (p PropertySchema) ValidateDateTimes(vals []time.Time) error {
if p.Type != PTDateTime {
return invalidType(PTDateTime, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
// Distinct datetimes rarely matter; honor it if requested.
if err := ensureDistinctByKey(vals, func(t time.Time) int64 { return t.UTC().UnixNano() }, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsDateTime()
if err != nil {
return err
}
for _, v := range vals {
vu := v.UTC()
if props.Earliest != nil && vu.Before(props.Earliest.UTC()) {
return merrors.DataConflict("datetime before earliest")
}
if props.Latest != nil && vu.After(props.Latest.UTC()) {
return merrors.DataConflict("datetime after latest")
}
}
return nil
}
// Monetary validation (handles currency policy + Min/Max + optional scale)
func (p PropertySchema) ValidateMonetaries(vals []Money, orgCurrency Currency) error {
if p.Type != PTMonetary {
return invalidType(PTMonetary, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
// Distinct by (currency, amount)
if err := ensureDistinctByKey(vals, func(m Money) string { return string(m.Currency) + "|" + m.Amount.String() }, p.Multiplicity.Distinct); err != nil {
return err
}
props, err := p.AsMonetary()
if err != nil {
return err
}
allowedCur := map[Currency]struct{}{}
if len(props.AllowedCurrencies) > 0 {
for _, c := range props.AllowedCurrencies {
allowedCur[c] = struct{}{}
}
}
for _, v := range vals {
// Currency policy
switch props.CurrencyMode {
case CurrencyFixed:
if props.FixedCurrency == "" {
return merrors.InvalidArgument("fixed currency is not configured")
}
if v.Currency != props.FixedCurrency {
return merrors.DataConflict(fmt.Sprintf("currency must be %s", props.FixedCurrency))
}
case CurrencyOrg:
if orgCurrency == "" {
return merrors.InvalidArgument("org currency not provided")
}
if v.Currency != Currency(orgCurrency) {
return merrors.DataConflict(fmt.Sprintf("currency must be %s", orgCurrency))
}
case CurrencyFree, "":
if len(allowedCur) > 0 {
if _, ok := allowedCur[v.Currency]; !ok {
return merrors.DataConflict(fmt.Sprintf("currency %s not allowed", v.Currency))
}
}
default:
return merrors.InvalidArgument(fmt.Sprintf("unknown currency mode: %s", props.CurrencyMode))
}
// Scale check (if configured)
if props.Scale != nil {
ok, frac := decimal128WithinScale(v.Amount, *props.Scale)
if !ok {
return merrors.DataConflict(fmt.Sprintf("too many decimal places: got %d, max %d", frac, *props.Scale))
}
}
// Min/Max (apply only if currencies match)
if props.Min != nil && props.Min.Currency == v.Currency {
cmp, cmpErr := compareDecimal128(v.Amount, props.Min.Amount)
if cmpErr == nil && cmp < 0 {
return merrors.DataConflict("amount below min")
}
}
if props.Max != nil && props.Max.Currency == v.Currency {
cmp, cmpErr := compareDecimal128(v.Amount, props.Max.Amount)
if cmpErr == nil && cmp > 0 {
return merrors.DataConflict("amount above max")
}
}
}
return nil
}
// References: existence check is injected.
type ExistFn func(resource mservice.Type, id primitive.ObjectID, filter bson.M) (bool, error)
func (p PropertySchema) ValidateReferences(vals []primitive.ObjectID, exist ExistFn) error {
if p.Type != PTReference {
return invalidType(PTReference, p.Type)
}
if err := validateMultiplicity(len(vals), false, p.Multiplicity); err != nil {
return err
}
props, err := p.AsReference()
if err != nil {
return err
}
// Distinct by referenced ID (and resource)
if err := ensureDistinctByKey(vals, func(r primitive.ObjectID) string { return props.Target + ":" + r.Hex() }, p.Multiplicity.Distinct); err != nil {
return err
}
allowed := map[primitive.ObjectID]struct{}{}
if len(props.AllowedIDs) > 0 {
for _, id := range props.AllowedIDs {
allowed[id] = struct{}{}
}
}
for _, v := range vals {
if len(allowed) > 0 {
if _, ok := allowed[v]; !ok {
return merrors.DataConflict(fmt.Sprintf("id %s not allowed", v.Hex()))
}
}
if exist != nil {
ok, exErr := exist(props.Target, v, bson.M{})
if exErr != nil {
return exErr
}
if !ok {
return merrors.DataConflict("referenced document not found or disallowed")
}
}
}
return nil
}
// ----------------------------
// Decimal128 utilities
// ----------------------------
// compareDecimal128 returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareDecimal128(a, b primitive.Decimal128) (int, error) {
as := a.String()
bs := b.String()
af, _, err := big.ParseFloat(as, 10, 128, big.ToNearestEven)
if err != nil {
return 0, merrors.InvalidArgument(err.Error())
}
bf, _, err := big.ParseFloat(bs, 10, 128, big.ToNearestEven)
if err != nil {
return 0, merrors.InvalidArgument(err.Error())
}
return af.Cmp(bf), nil
}
// decimal128WithinScale checks if the number of fractional digits is <= scale.
func decimal128WithinScale(d primitive.Decimal128, scale int) (ok bool, fracDigits int) {
// Normalize via big.Float to handle exponents; then trim trailing zeros.
s := d.String()
f, _, err := big.ParseFloat(s, 10, 128, big.ToNearestEven)
if err != nil {
fd := countFractionDigits(s)
return fd <= scale, fd
}
fixed := f.Text('f', 40) // enough precision
fixed = trimTrailingZeros(fixed)
fd := countFractionDigits(fixed)
return fd <= scale, fd
}
func countFractionDigits(s string) int {
dot := -1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return 0
}
return len(s) - dot - 1
}
func trimTrailingZeros(s string) string {
dot := -1
for i := 0; i < len(s); i++ {
if s[i] == '.' {
dot = i
break
}
}
if dot < 0 {
return s
}
j := len(s) - 1
for j > dot && s[j] == '0' {
j--
}
if j == dot {
return s[:dot]
}
return s[:j+1]
}

View File

@@ -1,23 +0,0 @@
package model
import (
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ReactionType string
const (
ThumbsUp ReactionType = "thumbs_up"
ThumbsDown ReactionType = "thumbs_down"
Heart ReactionType = "heart"
Laugh ReactionType = "laugh"
Question ReactionType = "question"
Exclamation ReactionType = "exclamation"
)
type Reaction struct {
PermissionBound `bson:",inline" json:",inline"`
Type ReactionType `json:"type"`
AuthorRef primitive.ObjectID `json:"authorRef"`
CommentRef primitive.ObjectID `json:"commentRef"`
}

View File

@@ -1,26 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/mservice"
)
type Status struct {
PermissionBound `bson:",inline" json:",inline"`
Colorable `bson:",inline" json:",inline"`
Icon string `bson:"icon" json:"icon"`
IsFinal bool `bson:"isFinal" json:"isFinal"`
}
func (*Status) Collection() string {
return mservice.Statuses
}
type StatusGroup struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Statuses []IndexableRef `bson:"statuses" json:"statuses"`
}
func (*StatusGroup) Collection() string {
return mservice.StatusGroups
}

View File

@@ -1,20 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Step struct {
storable.Base `bson:",inline" json:",inline"`
ArchivableBase `bson:",inline" json:",inline"`
Colorable `bson:",inline" json:",inline"`
StatusRef primitive.ObjectID `bson:"statusRef" json:"statusRef"` // Reference to dynamic status
NextSteps []primitive.ObjectID `bson:"nextSteps" json:"nextSteps"` // Allowed transitions
Automations []primitive.ObjectID `bson:"automations" json:"automations"` // Automatically executed steps
}
func (*Step) Collection() string {
return mservice.Steps
}

View File

@@ -1,23 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const TagRefsField = "tagRefs"
type Tag struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Colorable `bson:",inline" json:",inline"`
TypeRefs *[]mservice.Type `bson:"typeRefs,omitempty" json:"typeRefs,omitempty"`
}
func (*Tag) Collection() string {
return mservice.Tags
}
type Taggable struct {
TagRefs []primitive.ObjectID `bson:"tagRefs,omitempty" json:"tagRefs,omitempty"`
}

View File

@@ -1,26 +0,0 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Task struct {
PermissionBound `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Indexable `bson:",inline" json:",inline"`
Taggable `bson:",inline" json:",inline"`
StatusRef primitive.ObjectID `bson:"statusRef" json:"statusRef"` // Reference to the current Step
ReporterRef primitive.ObjectID `bson:"reporterRef" json:"reporterRef"` // Reference to the task reporter
AssigneeRef *primitive.ObjectID `bson:"assigneeRef,omitempty" json:"assigneeRef,omitempty"` // Reference to the user assigned
ProjectRef primitive.ObjectID `bson:"projectRef" json:"projectRef"` // Reference to the project
PriorityRef primitive.ObjectID `bson:"priorityRef" json:"priorityRef"` // Reference to dynamic priority
DueDate *time.Time `bson:"dueDate" json:"dueDate"`
Number int `bson:"number" json:"number"`
}
func (*Task) Collection() string {
return mservice.Tasks
}

View File

@@ -1,19 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Team struct {
storable.Base `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
OrganizationRef primitive.ObjectID `bson:"organizationRef" json:"organizationRef"`
MemberRefs []primitive.ObjectID `bson:"memberRefs" json:"memberRefs"`
SubTeamsRefs []primitive.ObjectID `bson:"subteamsRefs" json:"subteamsRefs"`
}
func (*Team) Collection() string {
return mservice.Teams
}

View File

@@ -1,751 +0,0 @@
// file: model/value.go
package model
import (
"time"
"github.com/mitchellh/mapstructure"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"github.com/tech/sendico/pkg/merrors"
)
// ----------------------------
// Assignment model (domain)
// ----------------------------
type Value struct {
PermissionBound `bson:",inline" json:",inline"`
Target ObjectRef `bson:"target" json:"target"`
Type PropertyType `bson:"type" json:"type"`
Cardinality Cardinality `bson:"cardinality" json:"cardinality"`
PropertySchemaRef primitive.ObjectID `bson:"propertySchemaRef" json:"propertySchemaRef"`
// Small typed shape via keys like: "string"/"strings", "integer"/"integers", etc.
Values SettingsT `bson:"data" json:"data" yaml:"data"`
}
type Money struct {
Amount primitive.Decimal128 `bson:"amount" json:"amount"`
Currency Currency `bson:"currency" json:"currency"`
}
type Object = map[string]Value
// ----------------------------
// SINGLE getters
// ----------------------------
func (v *Value) AsString() (string, error) {
if v.Type != PTString {
return "", invalidType(PTString, v.Type)
}
if v.Cardinality != One {
return "", merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value string `mapstructure:"string" bson:"string" json:"string" yaml:"string"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return "", err
}
return p.Value, nil
}
func (v *Value) AsColor() (string, error) {
if v.Type != PTColor {
return "", invalidType(PTColor, v.Type)
}
if v.Cardinality != One {
return "", merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value string `mapstructure:"color" bson:"color" json:"color" yaml:"color"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return "", err
}
return p.Value, nil
}
func (v *Value) AsInteger() (int64, error) {
if v.Type != PTInteger {
return 0, invalidType(PTInteger, v.Type)
}
if v.Cardinality != One {
return 0, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value int64 `mapstructure:"integer" bson:"integer" json:"integer" yaml:"integer"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return 0, err
}
return p.Value, nil
}
func (v *Value) AsFloat() (float64, error) {
if v.Type != PTFloat {
return 0, invalidType(PTFloat, v.Type)
}
if v.Cardinality != One {
return 0, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value float64 `mapstructure:"float" bson:"float" json:"float" yaml:"float"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return 0, err
}
return p.Value, nil
}
func (v *Value) AsDateTime() (time.Time, error) {
if v.Type != PTDateTime {
return time.Time{}, invalidType(PTDateTime, v.Type)
}
if v.Cardinality != One {
return time.Time{}, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value time.Time `mapstructure:"date_time" bson:"date_time" json:"date_time" yaml:"date_time"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return time.Time{}, err
}
return p.Value, nil
}
func (v *Value) AsMonetary() (Money, error) {
if v.Type != PTMonetary {
return Money{}, invalidType(PTMonetary, v.Type)
}
if v.Cardinality != One {
return Money{}, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value Money `mapstructure:"monetary" bson:"monetary" json:"monetary" yaml:"monetary"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return Money{}, err
}
return p.Value, nil
}
func (v *Value) AsReference() (primitive.ObjectID, error) {
if v.Type != PTReference {
return primitive.NilObjectID, invalidType(PTReference, v.Type)
}
if v.Cardinality != One {
return primitive.NilObjectID, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value primitive.ObjectID `mapstructure:"reference" bson:"reference" json:"reference" yaml:"reference"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return primitive.NilObjectID, err
}
return p.Value, nil
}
func (v *Value) AsObject() (Object, error) {
if v.Type != PTObject {
return nil, invalidType(PTObject, v.Type)
}
if v.Cardinality != One {
return nil, merrors.InvalidArgument("invalid cardinality: expected one")
}
type payload struct {
Value Object `mapstructure:"object" bson:"object" json:"object" yaml:"object"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Value, nil
}
// ----------------------------
// ARRAY getters
// ----------------------------
func (v *Value) AsStrings() ([]string, error) {
if v.Type != PTString {
return nil, invalidType(PTString, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []string `mapstructure:"strings" bson:"strings" json:"strings" yaml:"strings"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsColors() ([]string, error) {
if v.Type != PTColor {
return nil, invalidType(PTColor, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []string `mapstructure:"colors" bson:"colors" json:"colors" yaml:"colors"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsIntegers() ([]int64, error) {
if v.Type != PTInteger {
return nil, invalidType(PTInteger, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []int64 `mapstructure:"integers" bson:"integers" json:"integers" yaml:"integers"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsFloats() ([]float64, error) {
if v.Type != PTFloat {
return nil, invalidType(PTFloat, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []float64 `mapstructure:"floats" bson:"floats" json:"floats" yaml:"floats"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsDateTimes() ([]time.Time, error) {
if v.Type != PTDateTime {
return nil, invalidType(PTDateTime, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []time.Time `mapstructure:"date_times" bson:"date_times" json:"date_times" yaml:"date_times"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsMonetaries() ([]Money, error) {
if v.Type != PTMonetary {
return nil, invalidType(PTMonetary, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []Money `mapstructure:"monetaries" bson:"monetaries" json:"monetaries" yaml:"monetaries"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsReferences() ([]primitive.ObjectID, error) {
if v.Type != PTReference {
return nil, invalidType(PTReference, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []primitive.ObjectID `mapstructure:"references" bson:"references" json:"references" yaml:"references"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
func (v *Value) AsObjects() ([]Object, error) {
if v.Type != PTObject {
return nil, invalidType(PTObject, v.Type)
}
if v.Cardinality != Many {
return nil, merrors.InvalidArgument("invalid cardinality: expected many")
}
type payload struct {
Values []Object `mapstructure:"objects" bson:"objects" json:"objects" yaml:"objects"`
}
var p payload
if err := mapstructure.Decode(v.Values, &p); err != nil {
return nil, err
}
return p.Values, nil
}
// ----------------------------
// FACTORIES (scheme + value)
// ----------------------------
// Strings
func NewStringValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v string) (Value, error) {
if scheme.Type != PTString {
return Value{}, invalidType(PTString, scheme.Type)
}
if err := scheme.ValidateStrings([]string{v}); err != nil {
return Value{}, err
}
return Value{
PermissionBound: scope,
Target: target,
Type: PTString,
Cardinality: One,
PropertySchemaRef: scheme.ID,
Values: SettingsT{VKString: v},
}, nil
}
func NewStringsValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []string) (Value, error) {
if scheme.Type != PTString {
return Value{}, invalidType(PTString, scheme.Type)
}
if err := scheme.ValidateStrings(vv); err != nil {
return Value{}, err
}
return Value{
PermissionBound: scope,
Target: target,
Type: PTString,
Cardinality: Many,
PropertySchemaRef: scheme.ID,
Values: SettingsT{VKStrings: vv},
}, nil
}
// Colors
func NewColorValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v string) (Value, error) {
if scheme.Type != PTColor {
return Value{}, invalidType(PTColor, scheme.Type)
}
if err := scheme.ValidateColors([]string{v}); err != nil {
return Value{}, err
}
return Value{scope, target, PTColor, One, scheme.ID, SettingsT{VKColor: v}}, nil
}
func NewColorsValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []string) (Value, error) {
if scheme.Type != PTColor {
return Value{}, invalidType(PTColor, scheme.Type)
}
if err := scheme.ValidateColors(vv); err != nil {
return Value{}, err
}
return Value{scope, target, PTColor, Many, scheme.ID, SettingsT{VKColors: vv}}, nil
}
// Integers
func NewIntegerValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v int64) (Value, error) {
if scheme.Type != PTInteger {
return Value{}, invalidType(PTInteger, scheme.Type)
}
if err := scheme.ValidateIntegers([]int64{v}); err != nil {
return Value{}, err
}
return Value{scope, target, PTInteger, One, scheme.ID, SettingsT{VKInteger: v}}, nil
}
func NewIntegersValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []int64) (Value, error) {
if scheme.Type != PTInteger {
return Value{}, invalidType(PTInteger, scheme.Type)
}
if err := scheme.ValidateIntegers(vv); err != nil {
return Value{}, err
}
return Value{scope, target, PTInteger, Many, scheme.ID, SettingsT{VKIntegers: vv}}, nil
}
// Floats
func NewFloatValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v float64) (Value, error) {
if scheme.Type != PTFloat {
return Value{}, invalidType(PTFloat, scheme.Type)
}
if err := scheme.ValidateFloats([]float64{v}); err != nil {
return Value{}, err
}
return Value{scope, target, PTFloat, One, scheme.ID, SettingsT{VKFloat: v}}, nil
}
func NewFloatsValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []float64) (Value, error) {
if scheme.Type != PTFloat {
return Value{}, invalidType(PTFloat, scheme.Type)
}
if err := scheme.ValidateFloats(vv); err != nil {
return Value{}, err
}
return Value{scope, target, PTFloat, Many, scheme.ID, SettingsT{VKFloats: vv}}, nil
}
// DateTimes
func NewDateTimeValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v time.Time) (Value, error) {
if scheme.Type != PTDateTime {
return Value{}, invalidType(PTDateTime, scheme.Type)
}
if err := scheme.ValidateDateTimes([]time.Time{v}); err != nil {
return Value{}, err
}
return Value{scope, target, PTDateTime, One, scheme.ID, SettingsT{VKDateTime: v}}, nil
}
func NewDateTimesValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []time.Time) (Value, error) {
if scheme.Type != PTDateTime {
return Value{}, invalidType(PTDateTime, scheme.Type)
}
if err := scheme.ValidateDateTimes(vv); err != nil {
return Value{}, err
}
return Value{scope, target, PTDateTime, Many, scheme.ID, SettingsT{VKDateTimes: vv}}, nil
}
// Monetary (needs org currency for validation if required by scheme)
func NewMonetaryValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v Money, orgCurrency Currency) (Value, error) {
if scheme.Type != PTMonetary {
return Value{}, invalidType(PTMonetary, scheme.Type)
}
if err := scheme.ValidateMonetaries([]Money{v}, orgCurrency); err != nil {
return Value{}, err
}
return Value{scope, target, PTMonetary, One, scheme.ID, SettingsT{VKMonetary: v}}, nil
}
func NewMonetariesValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []Money, orgCurrency Currency) (Value, error) {
if scheme.Type != PTMonetary {
return Value{}, invalidType(PTMonetary, scheme.Type)
}
if err := scheme.ValidateMonetaries(vv, orgCurrency); err != nil {
return Value{}, err
}
return Value{scope, target, PTMonetary, Many, scheme.ID, SettingsT{VKMonetaries: vv}}, nil
}
// References (needs exist-fn)
func NewReferenceValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v primitive.ObjectID, exist ExistFn) (Value, error) {
if scheme.Type != PTReference {
return Value{}, invalidType(PTReference, scheme.Type)
}
if err := scheme.ValidateReferences([]primitive.ObjectID{v}, exist); err != nil {
return Value{}, err
}
return Value{scope, target, PTReference, One, scheme.ID, SettingsT{VKReference: v}}, nil
}
func NewReferencesValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []primitive.ObjectID, exist ExistFn) (Value, error) {
if scheme.Type != PTReference {
return Value{}, invalidType(PTReference, scheme.Type)
}
if err := scheme.ValidateReferences(vv, exist); err != nil {
return Value{}, err
}
return Value{scope, target, PTReference, Many, scheme.ID, SettingsT{VKReferences: vv}}, nil
}
// Objects (opaque maps)
func NewObjectValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, v Object) (Value, error) {
if scheme.Type != PTObject {
return Value{}, invalidType(PTObject, scheme.Type)
}
// Add your own ValidateObject if needed
return Value{scope, target, PTObject, One, scheme.ID, SettingsT{VKObject: v}}, nil
}
func NewObjectsValue(scope PermissionBound, target ObjectRef, scheme PropertySchema, vv []Object) (Value, error) {
if scheme.Type != PTObject {
return Value{}, invalidType(PTObject, scheme.Type)
}
return Value{scope, target, PTObject, Many, scheme.ID, SettingsT{VKObjects: vv}}, nil
}
// ----------------------------
// Custom BSON Marshalers/Unmarshalers
// ----------------------------
// MarshalBSON implements bson.Marshaler to ensure proper serialization
func (v Value) MarshalBSON() ([]byte, error) {
// Create a temporary struct that preserves the exact structure
temp := struct {
PermissionBound `bson:",inline"`
Target ObjectRef `bson:"target"`
Type PropertyType `bson:"type"`
Cardinality Cardinality `bson:"cardinality"`
PropertySchemaRef primitive.ObjectID `bson:"propertySchemaRef"`
Values SettingsTWrapper `bson:"data"`
}{
PermissionBound: v.PermissionBound,
Target: v.Target,
Type: v.Type,
Cardinality: v.Cardinality,
PropertySchemaRef: v.PropertySchemaRef,
Values: SettingsTWrapper(v.Values),
}
return bson.Marshal(temp)
}
// UnmarshalBSON implements bson.Unmarshaler to ensure proper deserialization
func (v *Value) UnmarshalBSON(data []byte) error {
// Create a temporary struct that matches the BSON structure
temp := struct {
PermissionBound `bson:",inline"`
Target ObjectRef `bson:"target"`
Type PropertyType `bson:"type"`
Cardinality Cardinality `bson:"cardinality"`
PropertySchemaRef primitive.ObjectID `bson:"propertySchemaRef"`
Values SettingsTWrapper `bson:"data"`
}{}
if err := bson.Unmarshal(data, &temp); err != nil {
return err
}
// Copy the values back to the original struct
v.PermissionBound = temp.PermissionBound
v.Target = temp.Target
v.Type = temp.Type
v.Cardinality = temp.Cardinality
v.PropertySchemaRef = temp.PropertySchemaRef
v.Values = SettingsT(temp.Values)
return nil
}
// ----------------------------
// Custom BSON Marshalers for SettingsT
// ----------------------------
// SettingsT is a type alias, so we need to define a wrapper type for methods
type SettingsTWrapper SettingsT
// MarshalBSON implements bson.Marshaler for SettingsT to preserve exact types
func (s SettingsTWrapper) MarshalBSON() ([]byte, error) {
// Convert SettingsT to bson.M to preserve exact types
doc := bson.M{}
for key, value := range s {
doc[key] = value
}
return bson.Marshal(doc)
}
// UnmarshalBSON implements bson.Unmarshaler for SettingsT to preserve exact types
func (s *SettingsTWrapper) UnmarshalBSON(data []byte) error {
// Unmarshal into a generic map first
var doc bson.M
if err := bson.Unmarshal(data, &doc); err != nil {
return err
}
// Convert back to SettingsT, preserving types
*s = make(SettingsT)
for key, value := range doc {
// Handle special cases where BSON converts types
switch v := value.(type) {
case primitive.A:
// Convert primitive.A back to appropriate slice type
if len(v) > 0 {
switch v[0].(type) {
case string:
strings := make([]string, len(v))
for i, item := range v {
strings[i] = item.(string)
}
(*s)[key] = strings
case int32, int64:
ints := make([]int64, len(v))
for i, item := range v {
switch val := item.(type) {
case int32:
ints[i] = int64(val)
case int64:
ints[i] = val
}
}
(*s)[key] = ints
case float32, float64:
floats := make([]float64, len(v))
for i, item := range v {
switch val := item.(type) {
case float32:
floats[i] = float64(val)
case float64:
floats[i] = val
}
}
(*s)[key] = floats
case primitive.DateTime:
times := make([]time.Time, len(v))
for i, item := range v {
times[i] = item.(primitive.DateTime).Time().Truncate(time.Millisecond)
}
(*s)[key] = times
case primitive.ObjectID:
refs := make([]primitive.ObjectID, len(v))
for i, item := range v {
refs[i] = item.(primitive.ObjectID)
}
(*s)[key] = refs
case bson.M:
// Handle nested objects (Money, Object, etc.)
if key == VKMonetaries {
// Handle Money slice
moneys := make([]Money, len(v))
for i, item := range v {
if itemMap, ok := item.(bson.M); ok {
var money Money
if amount, ok := itemMap[MKAmount].(primitive.Decimal128); ok {
money.Amount = amount
}
if currency, ok := itemMap[MKCurrency].(string); ok {
money.Currency = Currency(currency)
}
moneys[i] = money
}
}
(*s)[key] = moneys
} else {
// Handle Object slice
objects := make([]Object, len(v))
for i, item := range v {
obj := make(Object)
for k, val := range item.(bson.M) {
// Recursively handle nested Values
if valMap, ok := val.(bson.M); ok {
var nestedValue Value
if data, err := bson.Marshal(valMap); err == nil {
if err := bson.Unmarshal(data, &nestedValue); err == nil {
obj[k] = nestedValue
}
}
}
}
objects[i] = obj
}
(*s)[key] = objects
}
default:
// Fallback: keep as primitive.A
(*s)[key] = v
}
} else {
// Empty array - determine type from key name
switch key {
case VKStrings, VKColors:
(*s)[key] = []string{}
case VKIntegers:
(*s)[key] = []int64{}
case VKFloats:
(*s)[key] = []float64{}
case VKDateTimes:
(*s)[key] = []time.Time{}
case VKReferences:
(*s)[key] = []primitive.ObjectID{}
case VKMonetaries:
(*s)[key] = []Money{}
case VKObjects:
(*s)[key] = []Object{}
default:
(*s)[key] = []interface{}{}
}
}
case primitive.DateTime:
// Convert primitive.DateTime back to time.Time and truncate to millisecond precision
(*s)[key] = v.Time().Truncate(time.Millisecond)
case int64:
// Handle time.Time that gets converted to int64 (Unix timestamp)
if key == VKDateTime {
(*s)[key] = time.Unix(v, 0).UTC().Truncate(time.Millisecond)
} else {
(*s)[key] = v
}
case bson.M:
// Handle nested objects
if key == VKMonetary {
// Handle Money struct
var money Money
if amount, ok := v[MKAmount].(primitive.Decimal128); ok {
money.Amount = amount
}
if currency, ok := v[MKCurrency].(string); ok {
money.Currency = Currency(currency)
}
(*s)[key] = money
} else if key == VKMonetaries {
// Handle Money slice - this shouldn't happen in single values
(*s)[key] = v
} else if key == VKObject {
// Handle Object type
obj := make(Object)
for k, val := range v {
if valMap, ok := val.(bson.M); ok {
var nestedValue Value
if data, err := bson.Marshal(valMap); err == nil {
if err := bson.Unmarshal(data, &nestedValue); err == nil {
obj[k] = nestedValue
}
}
}
}
(*s)[key] = obj
} else {
// Generic map
(*s)[key] = v
}
case nil:
// Handle nil values - determine type from key name
switch key {
case VKStrings, VKColors:
(*s)[key] = []string(nil)
case VKIntegers:
(*s)[key] = []int64(nil)
case VKFloats:
(*s)[key] = []float64(nil)
case VKDateTimes:
(*s)[key] = []time.Time(nil)
case VKReferences:
(*s)[key] = []primitive.ObjectID(nil)
case VKMonetaries:
(*s)[key] = []Money(nil)
case VKObjects:
(*s)[key] = []Object(nil)
default:
(*s)[key] = nil
}
default:
// Keep as-is for primitive types
(*s)[key] = value
}
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Workflow struct {
storable.Base `bson:",inline" json:",inline"`
ArchivableBase `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Priorities []primitive.ObjectID `bson:"priorities" json:"priorities"` // Ordered list of StepRefs
Steps []primitive.ObjectID `bson:"steps" json:"steps"` // Ordered list of StepRefs
}
func (*Workflow) Collection() string {
return mservice.Workflows
}

View File

@@ -1,17 +0,0 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type Workspace struct {
storable.Base `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Projects []primitive.ObjectID `bson:"projects" json:"projects"` // References to projects in the workspace
}
func (*Workspace) Collection() string {
return mservice.Workspaces
}

View File

@@ -1,128 +0,0 @@
package helpers
import (
"context"
"testing"
factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TestInterfaceImplementation verifies that the concrete types implement the expected interfaces
func TestInterfaceImplementation(t *testing.T) {
logger := factory.NewLogger(true)
// Test TaskManager interface implementation
taskManager := NewTaskManager(logger, nil, nil)
var _ TaskManager = taskManager
// Test AccountManager interface implementation
accountManager := NewAccountManager(
logger,
nil, nil, nil, nil,
)
var _ AccountManager = accountManager
}
// TestInterfaceMethodSignatures ensures all interface methods have correct signatures
func TestInterfaceMethodSignatures(t *testing.T) {
logger := factory.NewLogger(true)
projectRef := primitive.NewObjectID()
statusRef := primitive.NewObjectID()
// Test TaskManager interface methods exist and have correct signatures
taskManager := NewTaskManager(logger, nil, nil)
task := &model.Task{
ProjectRef: projectRef,
StatusRef: statusRef,
}
task.SetID(primitive.NewObjectID())
// Verify method signatures exist (don't call them to avoid nil pointer panics)
var _ func(context.Context, primitive.ObjectID, primitive.ObjectID, *model.Task) error = taskManager.CreateTask
var _ func(context.Context, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID) error = taskManager.MoveTask
var _ func(context.Context, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID, primitive.ObjectID) error = taskManager.MoveTasks
var _ func(context.Context, primitive.ObjectID, primitive.ObjectID) error = taskManager.DeleteTask
// Test AccountManager interface methods exist and have correct signatures
accountManager := NewAccountManager(
logger,
nil, nil, nil, nil,
)
// Verify method signatures exist (don't call them to avoid nil pointer panics)
var _ func(context.Context, primitive.ObjectID) error = accountManager.DeleteAccount
var _ func(context.Context, primitive.ObjectID) error = accountManager.DeleteOrganization
var _ func(context.Context, primitive.ObjectID, primitive.ObjectID) error = accountManager.DeleteAll
}
// TestFactoryFunctionConsistency ensures factory functions return consistent types
func TestFactoryFunctionConsistency(t *testing.T) {
logger := factory.NewLogger(true)
// Create multiple instances to ensure consistency
for i := 0; i < 3; i++ {
taskManager := NewTaskManager(logger, nil, nil)
if taskManager == nil {
t.Fatalf("NewTaskManager returned nil on iteration %d", i)
}
accountManager := NewAccountManager(
logger,
nil, nil, nil, nil,
)
if accountManager == nil {
t.Fatalf("NewAccountManager returned nil on iteration %d", i)
}
}
}
// TestErrorHandlingWithNilDependencies ensures helpers handle nil dependencies gracefully
func TestErrorHandlingWithNilDependencies(t *testing.T) {
logger := factory.NewLogger(true)
// Test that creating helpers with nil dependencies doesn't panic
taskManager := NewTaskManager(logger, nil, nil)
if taskManager == nil {
t.Fatal("TaskManager should not be nil even with nil dependencies")
}
accountManager := NewAccountManager(
logger,
nil, nil, nil, nil,
)
if accountManager == nil {
t.Fatal("AccountManager should not be nil even with nil dependencies")
}
// The actual method calls would panic with nil dependencies,
// but that's expected behavior - the constructors should handle nil gracefully
t.Log("Helper managers created successfully with nil dependencies")
}
// TestHelperManagersDocumentedBehavior verifies expected behavior from documentation/comments
func TestHelperManagersDocumentedBehavior(t *testing.T) {
logger := factory.NewLogger(true)
// TaskManager is documented to handle task operations with proper ordering and numbering
taskManager := NewTaskManager(logger, nil, nil)
if taskManager == nil {
t.Fatal("TaskManager should be created successfully")
}
// AccountManager is documented to handle account management operations with cascade deletion
accountManager := NewAccountManager(
logger,
nil, nil, nil, nil,
)
if accountManager == nil {
t.Fatal("AccountManager should be created successfully")
}
// Both should be transaction-aware (caller responsible for transactions according to comments)
// This is more of a documentation test than a functional test
t.Log("TaskManager and AccountManager created successfully - transaction handling is caller's responsibility")
}

View File

@@ -6,21 +6,6 @@ import (
factory "github.com/tech/sendico/pkg/mlogger/factory"
)
func TestNewTaskManagerInternal(t *testing.T) {
logger := factory.NewLogger(true)
manager := NewTaskManager(logger, nil, nil)
if manager == nil {
t.Fatal("Expected non-nil TaskManager")
}
// Test that logger is properly named
if manager.logger == nil {
t.Error("Expected logger to be set")
}
}
func TestNewAccountManagerInternal(t *testing.T) {
logger := factory.NewLogger(true)
@@ -38,19 +23,3 @@ func TestNewAccountManagerInternal(t *testing.T) {
t.Error("Expected logger to be set")
}
}
func TestInternalConstructorsWithNilLogger(t *testing.T) {
// Test that constructors handle nil logger gracefully
taskManager := NewTaskManager(nil, nil, nil)
if taskManager == nil {
t.Fatal("Expected non-nil TaskManager even with nil logger")
}
accountManager := NewAccountManager(
nil,
nil, nil, nil, nil,
)
if accountManager == nil {
t.Fatal("Expected non-nil AccountManager even with nil logger")
}
}

View File

@@ -1,267 +0,0 @@
package internal
import (
"testing"
"github.com/tech/sendico/pkg/merrors"
factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TestTaskManager_BusinessRules tests the core business rules of task management
func TestTaskManager_BusinessRules(t *testing.T) {
logger := factory.NewLogger(true)
_ = NewTaskManager(logger, nil, nil) // Ensure constructor works
t.Run("TaskNumberIncrementRule", func(t *testing.T) {
// Business Rule: Each new task should get the next available number from the project
// This tests that the business logic understands the numbering system
// Create a project with NextTaskNumber = 5
project := &model.Project{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: primitive.NewObjectID(),
},
},
Describable: model.Describable{Name: "Test Project"},
Mnemonic: "TEST",
},
NextTaskNumber: 5,
}
// Business rule: The next task should get number 5
expectedTaskNumber := project.NextTaskNumber
if expectedTaskNumber != 5 {
t.Errorf("Business rule violation: Next task should get number %d, but project has %d", 5, expectedTaskNumber)
}
// Business rule: After creating a task, the project's NextTaskNumber should increment
project.NextTaskNumber++
if project.NextTaskNumber != 6 {
t.Errorf("Business rule violation: Project NextTaskNumber should increment to %d, but got %d", 6, project.NextTaskNumber)
}
})
t.Run("TaskIndexAssignmentRule", func(t *testing.T) {
// Business Rule: Each new task should get an index that's one more than the current max
// This tests the ordering logic
// Simulate existing tasks with indices [1, 3, 5]
existingIndices := []int{1, 3, 5}
maxIndex := -1
for _, idx := range existingIndices {
if idx > maxIndex {
maxIndex = idx
}
}
// Business rule: New task should get index = maxIndex + 1
expectedNewIndex := maxIndex + 1
if expectedNewIndex != 6 {
t.Errorf("Business rule violation: New task should get index %d, but calculated %d", 6, expectedNewIndex)
}
})
t.Run("TaskMoveNumberingRule", func(t *testing.T) {
// Business Rule: When moving a task to a new project, it should get a new number from the target project
// Target project has NextTaskNumber = 25
targetProject := &model.Project{
NextTaskNumber: 25,
}
// Business rule: Moved task should get number from target project
expectedTaskNumber := targetProject.NextTaskNumber
if expectedTaskNumber != 25 {
t.Errorf("Business rule violation: Moved task should get number %d from target project, but got %d", 25, expectedTaskNumber)
}
// Business rule: Target project NextTaskNumber should increment
targetProject.NextTaskNumber++
if targetProject.NextTaskNumber != 26 {
t.Errorf("Business rule violation: Target project NextTaskNumber should increment to %d, but got %d", 26, targetProject.NextTaskNumber)
}
})
t.Run("TaskOrderingRule", func(t *testing.T) {
// Business Rule: Tasks should maintain proper ordering within a status
// This tests the ensureProperOrdering logic
// Business rule: Tasks should be ordered by index
// After reordering, they should be: [Task2(index=1), Task1(index=2), Task3(index=3)]
expectedOrder := []string{"Task2", "Task1", "Task3"}
expectedIndices := []int{1, 2, 3}
// This simulates what ensureProperOrdering should do
for i, expectedTask := range expectedOrder {
expectedIndex := expectedIndices[i]
t.Logf("Business rule: %s should have index %d after reordering", expectedTask, expectedIndex)
}
})
}
// TestTaskManager_ErrorScenarios tests error handling scenarios
func TestTaskManager_ErrorScenarios(t *testing.T) {
t.Run("ProjectNotFoundError", func(t *testing.T) {
// Business Rule: Creating a task for a non-existent project should return an error
// This simulates the error that should occur when projectDB.Get() fails
err := merrors.NoData("project not found")
// Business rule: Should return an error
if err == nil {
t.Error("Business rule violation: Project not found should return an error")
}
})
t.Run("TaskNotFoundError", func(t *testing.T) {
// Business Rule: Moving a non-existent task should return an error
// This simulates the error that should occur when taskDB.Get() fails
err := merrors.NoData("task not found")
// Business rule: Should return an error
if err == nil {
t.Error("Business rule violation: Task not found should return an error")
}
})
t.Run("DatabaseUpdateError", func(t *testing.T) {
// Business Rule: If project update fails after task creation, it should be logged as a warning
// This tests the error handling in the business logic
// Simulate a database update error
updateError := merrors.NoData("database update failed")
// Business rule: Database errors should be handled gracefully
if updateError == nil {
t.Error("Business rule violation: Database errors should be detected and handled")
}
})
}
// TestTaskManager_DataIntegrity tests data integrity rules
func TestTaskManager_DataIntegrity(t *testing.T) {
t.Run("TaskNumberUniqueness", func(t *testing.T) {
// Business Rule: Task numbers within a project should be unique
// Simulate existing task numbers in a project
existingNumbers := map[int]bool{
1: true,
2: true,
3: true,
}
// Business rule: Next task number should not conflict with existing numbers
nextNumber := 4
if existingNumbers[nextNumber] {
t.Error("Business rule violation: Next task number should not conflict with existing numbers")
}
})
t.Run("TaskIndexUniqueness", func(t *testing.T) {
// Business Rule: Task indices within a status should be unique
// Simulate existing task indices in a status
existingIndices := map[int]bool{
1: true,
2: true,
3: true,
}
// Business rule: Next task index should not conflict with existing indices
nextIndex := 4
if existingIndices[nextIndex] {
t.Error("Business rule violation: Next task index should not conflict with existing indices")
}
})
t.Run("ProjectReferenceIntegrity", func(t *testing.T) {
// Business Rule: Tasks must have valid project references
// Valid project reference
validProjectRef := primitive.NewObjectID()
if validProjectRef.IsZero() {
t.Error("Business rule violation: Project reference should not be zero")
}
// Invalid project reference (zero value)
invalidProjectRef := primitive.ObjectID{}
if !invalidProjectRef.IsZero() {
t.Error("Business rule violation: Zero ObjectID should be detected as invalid")
}
})
}
// TestTaskManager_WorkflowScenarios tests complete workflow scenarios
func TestTaskManager_WorkflowScenarios(t *testing.T) {
t.Run("CompleteTaskLifecycle", func(t *testing.T) {
// Business Rule: Complete workflow from task creation to deletion should maintain data integrity
// Step 1: Project setup
project := &model.Project{
ProjectBase: model.ProjectBase{
PermissionBound: model.PermissionBound{
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: primitive.NewObjectID(),
},
},
Describable: model.Describable{Name: "Workflow Project"},
Mnemonic: "WORK",
},
NextTaskNumber: 1,
}
// Step 2: Task creation workflow
// Business rule: Task should get number 1
taskNumber := project.NextTaskNumber
if taskNumber != 1 {
t.Errorf("Workflow violation: First task should get number %d, but got %d", 1, taskNumber)
}
// Business rule: Project NextTaskNumber should increment
project.NextTaskNumber++
if project.NextTaskNumber != 2 {
t.Errorf("Workflow violation: Project NextTaskNumber should be %d after first task, but got %d", 2, project.NextTaskNumber)
}
// Step 3: Task move workflow
// Business rule: Moving task should not affect source project's NextTaskNumber
// (since the task already exists)
originalSourceNextNumber := project.NextTaskNumber
if originalSourceNextNumber != 2 {
t.Errorf("Workflow violation: Source project NextTaskNumber should remain %d, but got %d", 2, originalSourceNextNumber)
}
})
t.Run("BulkTaskMoveScenario", func(t *testing.T) {
// Business Rule: Moving multiple tasks should maintain proper numbering
// Source project with 3 tasks
sourceProject := &model.Project{
NextTaskNumber: 4, // Next task would be #4
}
// Target project
targetProject := &model.Project{
NextTaskNumber: 10, // Next task would be #10
}
// Business rule: Moving 3 tasks should increment target project by 3
tasksToMove := 3
expectedTargetNextNumber := targetProject.NextTaskNumber + tasksToMove
if expectedTargetNextNumber != 13 {
t.Errorf("Workflow violation: Target project NextTaskNumber should be %d after moving %d tasks, but calculated %d", 13, tasksToMove, expectedTargetNextNumber)
}
// Business rule: Source project NextTaskNumber should remain unchanged
// (since we're moving existing tasks, not creating new ones)
expectedSourceNextNumber := sourceProject.NextTaskNumber
if expectedSourceNextNumber != 4 {
t.Errorf("Workflow violation: Source project NextTaskNumber should remain %d, but got %d", 4, expectedSourceNextNumber)
}
})
}

View File

@@ -1,110 +0,0 @@
package internal
import (
"context"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TaskManager is a placeholder implementation that validates input and provides a consistent
// constructor until the full business logic is available.
type TaskManager struct {
logger mlogger.Logger
projectDB any
taskDB any
}
// NewTaskManager creates a new TaskManager instance.
func NewTaskManager(logger mlogger.Logger, projectDB, taskDB any) *TaskManager {
var namedLogger mlogger.Logger
if logger != nil {
namedLogger = logger.Named("task_manager")
}
return &TaskManager{
logger: namedLogger,
projectDB: projectDB,
taskDB: taskDB,
}
}
func (m *TaskManager) CreateTask(ctx context.Context, accountRef, organizationRef primitive.ObjectID, task *model.Task) error {
if ctx == nil {
return merrors.InvalidArgument("context is nil")
}
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is zero")
}
if organizationRef.IsZero() {
return merrors.InvalidArgument("organization reference is zero")
}
if task == nil {
return merrors.InvalidArgument("task is nil")
}
if task.ProjectRef.IsZero() {
return merrors.InvalidArgument("task.projectRef is zero")
}
if task.StatusRef.IsZero() {
return merrors.InvalidArgument("task.statusRef is zero")
}
return merrors.NotImplemented("task manager CreateTask requires data layer integration")
}
func (m *TaskManager) MoveTask(ctx context.Context, accountRef, organizationRef primitive.ObjectID, taskRef, targetProjectRef, targetStatusRef primitive.ObjectID) error {
if ctx == nil {
return merrors.InvalidArgument("context is nil")
}
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is zero")
}
if organizationRef.IsZero() {
return merrors.InvalidArgument("organization reference is zero")
}
if taskRef.IsZero() {
return merrors.InvalidArgument("task reference is zero")
}
if targetProjectRef.IsZero() {
return merrors.InvalidArgument("target project reference is zero")
}
if targetStatusRef.IsZero() {
return merrors.InvalidArgument("target status reference is zero")
}
return merrors.NotImplemented("task manager MoveTask requires data layer integration")
}
func (m *TaskManager) MoveTasks(ctx context.Context, accountRef, organizationRef, sourceProjectRef, targetProjectRef, targetStatusRef primitive.ObjectID) error {
if ctx == nil {
return merrors.InvalidArgument("context is nil")
}
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is zero")
}
if organizationRef.IsZero() {
return merrors.InvalidArgument("organization reference is zero")
}
if sourceProjectRef.IsZero() {
return merrors.InvalidArgument("source project reference is zero")
}
if targetProjectRef.IsZero() {
return merrors.InvalidArgument("target project reference is zero")
}
if targetStatusRef.IsZero() {
return merrors.InvalidArgument("target status reference is zero")
}
return merrors.NotImplemented("task manager MoveTasks requires data layer integration")
}
func (m *TaskManager) DeleteTask(ctx context.Context, accountRef, taskRef primitive.ObjectID) error {
if ctx == nil {
return merrors.InvalidArgument("context is nil")
}
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is zero")
}
if taskRef.IsZero() {
return merrors.InvalidArgument("task reference is zero")
}
return merrors.NotImplemented("task manager DeleteTask requires data layer integration")
}

View File

@@ -6,17 +6,6 @@ import (
factory "github.com/tech/sendico/pkg/mlogger/factory"
)
func TestNewTaskManagerFactory(t *testing.T) {
logger := factory.NewLogger(true)
// Test that factory doesn't panic with nil dependencies
taskManager := NewTaskManager(logger, nil, nil)
if taskManager == nil {
t.Fatal("Expected non-nil TaskManager")
}
}
func TestNewAccountManagerFactory(t *testing.T) {
logger := factory.NewLogger(true)
@@ -30,38 +19,3 @@ func TestNewAccountManagerFactory(t *testing.T) {
t.Fatal("Expected non-nil AccountManager")
}
}
func TestFactoriesWithNilLogger(t *testing.T) {
// Test that factories handle nil logger gracefully
taskManager := NewTaskManager(nil, nil, nil)
if taskManager == nil {
t.Fatal("Expected non-nil TaskManager even with nil logger")
}
accountManager := NewAccountManager(
nil,
nil, nil, nil, nil,
)
if accountManager == nil {
t.Fatal("Expected non-nil AccountManager even with nil logger")
}
}
func TestFactoryTypesCompile(t *testing.T) {
// This test verifies that the factory functions return the expected interface types
logger := factory.NewLogger(true)
var taskManager TaskManager = NewTaskManager(logger, nil, nil)
var accountManager AccountManager = NewAccountManager(
logger,
nil, nil, nil, nil,
)
// These should not be nil
if taskManager == nil {
t.Fatal("TaskManager should not be nil")
}
if accountManager == nil {
t.Fatal("AccountManager should not be nil")
}
}

View File

@@ -1,27 +0,0 @@
package helpers
import (
"context"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TaskManager defines the interface for task management operations
type TaskManager interface {
// CreateTask creates a new task with proper ordering and numbering
// The caller is responsible for wrapping this in a transaction
CreateTask(ctx context.Context, accountRef, organizationRef primitive.ObjectID, task *model.Task) error
// MoveTask moves a task to a new project and status with proper ordering and numbering
// The caller is responsible for wrapping this in a transaction
MoveTask(ctx context.Context, accountRef, organizationRef primitive.ObjectID, taskRef, targetProjectRef, targetStatusRef primitive.ObjectID) error
// MoveTasks moves multiple tasks to a new project and status with proper ordering and numbering
// The caller is responsible for wrapping this in a transaction
MoveTasks(ctx context.Context, accountRef, organizationRef, sourceProjectRef, targetProjectRef, targetStatusRef primitive.ObjectID) error
// DeleteTask deletes a task and updates the project if necessary
// The caller is responsible for wrapping this in a transaction
DeleteTask(ctx context.Context, accountRef, taskRef primitive.ObjectID) error
}

View File

@@ -1,11 +0,0 @@
package helpers
import (
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/helpers/internal"
)
// NewTaskManager proxies to the internal implementation while exposing the public interface.
func NewTaskManager(logger mlogger.Logger, projectDB, taskDB any) TaskManager {
return internal.NewTaskManager(logger, projectDB, taskDB)
}