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

BIN
api/.DS_Store vendored Normal file

Binary file not shown.

BIN
api/notification/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,57 @@
# Config file for [Air](https://github.com/air-verse/air) in TOML format
# Working directory
# . or absolute path, please note that the directories following must be under root.
root = "./.."
tmp_dir = "tmp"
[build]
# Just plain old shell command. You could use `make` as well.
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/notification/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/notification/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/notification/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/notification/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/notification/internal/appversion.BuildDate=$(date)' -X 'github.com/tech/sendico/notification/internal/mutil/ampli.Version=$APP_V'\""
# Binary file yields from `cmd`.
bin = "./app"
# Customize binary, can setup environment variables when run your app.
full_bin = "./app --debug"
# Watch these filename extensions.
include_ext = ["go", "tpl", "tmpl", "html"]
# Ignore these filename extensions or directories.
exclude_dir = ["notification/.git", "pkg/.git", "notification/tmp", "notification/resources", "notification/env"]
# Watch these directories if you specified.
include_dir = []
# Watch these files.
include_file = []
# Exclude files.
exclude_file = []
# Exclude specific regular expressions.
exclude_regex = ["_test\\.go"]
# Exclude unchanged files.
exclude_unchanged = true
# Follow symlink for directories
follow_symlink = true
# This log file places in your tmp_dir.
log = "air.log"
# It's not necessary to trigger build each time file changes if it's too frequent.
delay = 0 # ms
# Stop running old binary when build errors occur.
stop_on_error = true
# Send Interrupt signal before killing process (windows does not support this feature)
send_interrupt = true
# Delay after sending Interrupt signal
kill_delay = 500 # ms
# Add additional arguments when running binary (bin/full_bin). Will run './tmp/main hello world'.
args_bin = []
[log]
# Show log time
time = false
[color]
# Customize each part's color. If no color found, use the raw app log.
main = "magenta"
watcher = "cyan"
build = "yellow"
runner = "green"
[misc]
# Delete tmp directory on exit
clean_on_exit = true

1
api/notification/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
go.sum

View File

@@ -0,0 +1,14 @@
{
"Zone": "eu",
"OrgId": "100001828",
"WorkspaceId": "c75043a3-1fad-45ec-bd71-c807a99c650d",
"SourceId": "81b24ac7-e285-4519-9e82-bb575601120c",
"Branch": "main",
"Version": "2.0.0",
"VersionId": "4fa6851a-4ff0-42f1-b440-8b39f07870e4",
"Runtime": "go:go-ampli",
"Platform": "Go",
"Language": "Go",
"SDK": "analytics-go",
"Path": "./internal/ampli"
}

85
api/notification/config.yml Executable file
View File

@@ -0,0 +1,85 @@
http_server:
listen_address: :8081
read_header_timeout: 60
shutdown_timeout: 5
api:
amplitude:
ampli_environment_env: AMPLI_ENVIRONMENT
middleware:
api_protocol_env: API_PROTOCOL
domain_env: SERVICE_HOST
api_endpoint_env: API_ENDPOINT
signature:
secret_key_env: API_ENDPOINT_SECRET
algorithm: HS256
CORS:
max_age: 300
allowed_origins:
- "http://*"
- "https://*"
allowed_methods:
- "GET"
- "POST"
- "PATCH"
- "DELETE"
- "OPTIONS"
allowed_headers:
- "Accept"
- "Authorization"
- "Content-Type"
exposed_headers:
allow_credentials: false
websocket:
endpoint_env: WS_ENDPOINT
timeout: 60
message_broker:
driver: NATS
settings:
host_env: NATS_HOST
port_env: NATS_PORT
username_env: NATS_USER
password_env: NATS_PASSWORD
broker_name: Sendico Notification server
max_reconnects: 10
reconnect_wait: 5
# type: in-process
# settings:
# buffer_size: 10
notification:
driver: client
settings:
username_env: MAIL_USER
password_env: MAIL_SECRET
host: "smtp.mail.ru"
port: 465
from: "MeetX Tech"
network_timeout: 10
localizer:
path: "./i18n"
languages: ["en", "ru", "uk"]
service_name: "MeetX Connectica"
support: "support@meetx.space"
app:
database:
driver: mongodb
settings:
host_env: MONGO_HOST
port_env: MONGO_PORT
database_env: MONGO_DATABASE
user_env: MONGO_USER
password_env: MONGO_PASSWORD
auth_source_env: MONGO_AUTH_SOURCE
replica_set_env: MONGO_REPLICA_SET
enforcer:
driver: native
settings:
model_path_env: PERMISSION_MODEL
adapter:
collection_name_env: PERMISSION_COLLECTION
database_name_env: MONGO_DATABASE
timeout_seconds_env: PERMISSION_TIMEOUT
is_filtered_env: PERMISSION_IS_FILTERED

1
api/notification/env/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env.api

58
api/notification/go.mod Normal file
View File

@@ -0,0 +1,58 @@
module github.com/tech/sendico/notification
go 1.25.3
replace github.com/tech/sendico/pkg => ../pkg
require (
github.com/amplitude/analytics-go v1.2.0
github.com/go-chi/chi/v5 v5.2.3
github.com/mitchellh/mapstructure v1.5.0
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/sendgrid/sendgrid-go v3.16.1+incompatible
github.com/tech/sendico/pkg v0.1.0
github.com/xhit/go-simple-mail/v2 v2.16.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.0
golang.org/x/text v0.30.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.132.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.47.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
)
type API interface {
Config() *Config
DBFactory() db.Factory
Logger() mlogger.Logger
Register() messaging.Register
Localizer() localizer.Localizer
DomainProvider() domainprovider.DomainProvider
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)

View File

@@ -0,0 +1,13 @@
package api
import (
mwa "github.com/tech/sendico/notification/interface/middleware"
amp "github.com/tech/sendico/notification/interface/services/amplitude/config"
not "github.com/tech/sendico/notification/interface/services/notification/config"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
Notification *not.Config `yaml:"notification"`
Amplitude *amp.Config `yaml:"amplitude"`
}

View File

@@ -0,0 +1,19 @@
package localizer
import (
lclrimp "github.com/tech/sendico/notification/internal/localizer"
"github.com/tech/sendico/pkg/mlogger"
)
type Config = *lclrimp.Config
type Localizer interface {
LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error)
LocalizeString(id, lang string) (string, error)
ServiceName() string
SupportMail() string
}
func CreateLocalizer(logger mlogger.Logger, config *Config) (Localizer, error) {
return lclrimp.CreateLocalizer(logger, *config)
}

View File

@@ -0,0 +1,12 @@
package middleware
import (
ai "github.com/tech/sendico/notification/internal/api/config"
)
type (
Config = ai.Config
Signature = ai.SignatureConf
)
type MapClaims = ai.MapClaims

View File

@@ -0,0 +1,11 @@
package amplitude
import (
"github.com/tech/sendico/notification/interface/api"
ampliimp "github.com/tech/sendico/notification/internal/server/amplitude"
"github.com/tech/sendico/pkg/mservice"
)
func Create(a api.API) (mservice.MicroService, error) {
return ampliimp.CreateAPI(a)
}

View File

@@ -0,0 +1,5 @@
package amplitude
type Config struct {
Environment string `yaml:"ampli_environment_env"`
}

View File

@@ -0,0 +1,6 @@
package notificationimp
type Config struct {
Driver string `yaml:"driver"`
Settings map[string]any `yaml:"settings,omitempty"`
}

View File

@@ -0,0 +1,11 @@
package notification
import (
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/internal/server/notificationimp"
"github.com/tech/sendico/pkg/mservice"
)
func Create(a api.API) (mservice.MicroService, error) {
return notificationimp.CreateAPI(a)
}

View File

@@ -0,0 +1,734 @@
// ampli.go
//
// Ampli - A strong typed wrapper for your Analytics
//
// This file is generated by Amplitude.
// To update run 'ampli pull backend'
//
// Required dependencies: github.com/amplitude/analytics-go@latest
// Tracking Plan Version: 2
// Build: 1.0.0
// Runtime: go-ampli
//
// View Tracking Plan: https://data.eu.amplitude.com/profee/Profee%20Tips/events/main/latest
//
// Full Setup Instructions: https://data.eu.amplitude.com/profee/Profee%20Tips/implementation/main/latest/getting-started/backend
//
package ampli
import (
"log"
"sync"
"github.com/amplitude/analytics-go/amplitude"
)
type (
EventOptions = amplitude.EventOptions
ExecuteResult = amplitude.ExecuteResult
)
const (
IdentifyEventType = amplitude.IdentifyEventType
GroupIdentifyEventType = amplitude.GroupIdentifyEventType
ServerZoneUS = amplitude.ServerZoneUS
ServerZoneEU = amplitude.ServerZoneEU
)
var (
NewClientConfig = amplitude.NewConfig
NewClient = amplitude.NewClient
)
var Instance = Ampli{}
type Environment string
const (
EnvironmentProfeetips Environment = `profeetips`
)
var APIKey = map[Environment]string{
EnvironmentProfeetips: `c4e543cf70e8c83b85eb56e9a1d9b4b3`,
}
// LoadClientOptions is Client options setting to initialize Ampli client.
//
// Params:
// - APIKey: the API key of Amplitude project
// - Instance: the core SDK instance used by Ampli client
// - Configuration: the core SDK client configuration instance
type LoadClientOptions struct {
APIKey string
Instance amplitude.Client
Configuration amplitude.Config
}
// LoadOptions is options setting to initialize Ampli client.
//
// Params:
// - Environment: the environment of Amplitude Data project
// - Disabled: the flag of disabled Ampli client
// - Client: the LoadClientOptions struct
type LoadOptions struct {
Environment Environment
Disabled bool
Client LoadClientOptions
}
type baseEvent struct {
eventType string
properties map[string]any
}
type Event interface {
ToAmplitudeEvent() amplitude.Event
}
func newBaseEvent(eventType string, properties map[string]any) baseEvent {
return baseEvent{
eventType: eventType,
properties: properties,
}
}
func (event baseEvent) ToAmplitudeEvent() amplitude.Event {
return amplitude.Event{
EventType: event.eventType,
EventProperties: event.properties,
}
}
var EmailOpened = struct {
Builder func() interface {
EmailType(emailType string) EmailOpenedBuilder
}
}{
Builder: func() interface {
EmailType(emailType string) EmailOpenedBuilder
} {
return &emailOpenedBuilder{
properties: map[string]any{},
}
},
}
type EmailOpenedEvent interface {
Event
emailOpened()
}
type emailOpenedEvent struct {
baseEvent
}
func (e emailOpenedEvent) emailOpened() {
}
type EmailOpenedBuilder interface {
Build() EmailOpenedEvent
}
type emailOpenedBuilder struct {
properties map[string]any
}
func (b *emailOpenedBuilder) EmailType(emailType string) EmailOpenedBuilder {
b.properties[`emailType`] = emailType
return b
}
func (b *emailOpenedBuilder) Build() EmailOpenedEvent {
return &emailOpenedEvent{
newBaseEvent(`emailOpened`, b.properties),
}
}
var EmailSent = struct {
Builder func() interface {
Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
}
}
}{
Builder: func() interface {
Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
}
} {
return &emailSentBuilder{
properties: map[string]any{},
}
},
}
type EmailSentEvent interface {
Event
emailSent()
}
type emailSentEvent struct {
baseEvent
}
func (e emailSentEvent) emailSent() {
}
type EmailSentBuilder interface {
Build() EmailSentEvent
}
type emailSentBuilder struct {
properties map[string]any
}
func (b *emailSentBuilder) Domain(domain string) interface {
EmailType(emailType string) EmailSentBuilder
} {
b.properties[`domain`] = domain
return b
}
func (b *emailSentBuilder) EmailType(emailType string) EmailSentBuilder {
b.properties[`emailType`] = emailType
return b
}
func (b *emailSentBuilder) Build() EmailSentEvent {
return &emailSentEvent{
newBaseEvent(`emailSent`, b.properties),
}
}
var PaymentFailed = struct {
Builder func() interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
}
}
}{
Builder: func() interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
}
} {
return &paymentFailedBuilder{
properties: map[string]any{},
}
},
}
type PaymentFailedEvent interface {
Event
paymentFailed()
}
type paymentFailedEvent struct {
baseEvent
}
func (e paymentFailedEvent) paymentFailed() {
}
type PaymentFailedBuilder interface {
Build() PaymentFailedEvent
Comment(comment string) PaymentFailedBuilder
Source(source string) PaymentFailedBuilder
}
type paymentFailedBuilder struct {
properties map[string]any
}
func (b *paymentFailedBuilder) Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
}
} {
b.properties[`amount`] = amount
return b
}
func (b *paymentFailedBuilder) Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
}
} {
b.properties[`domain`] = domain
return b
}
func (b *paymentFailedBuilder) Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
}
} {
b.properties[`fee`] = fee
return b
}
func (b *paymentFailedBuilder) FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
}
} {
b.properties[`feeCoveredBy`] = feeCoveredBy
return b
}
func (b *paymentFailedBuilder) Product(product string) interface {
ProductQty(productQty int) PaymentFailedBuilder
} {
b.properties[`product`] = product
return b
}
func (b *paymentFailedBuilder) ProductQty(productQty int) PaymentFailedBuilder {
b.properties[`product_qty`] = productQty
return b
}
func (b *paymentFailedBuilder) Comment(comment string) PaymentFailedBuilder {
b.properties[`comment`] = comment
return b
}
func (b *paymentFailedBuilder) Source(source string) PaymentFailedBuilder {
b.properties[`source`] = source
return b
}
func (b *paymentFailedBuilder) Build() PaymentFailedEvent {
return &paymentFailedEvent{
newBaseEvent(`paymentFailed`, b.properties),
}
}
var PaymentSuccess = struct {
Builder func() interface {
Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
}
}
}{
Builder: func() interface {
Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
}
} {
return &paymentSuccessBuilder{
properties: map[string]any{},
}
},
}
type PaymentSuccessEvent interface {
Event
paymentSuccess()
}
type paymentSuccessEvent struct {
baseEvent
}
func (e paymentSuccessEvent) paymentSuccess() {
}
type PaymentSuccessBuilder interface {
Build() PaymentSuccessEvent
Quantity(quantity int) PaymentSuccessBuilder
Comment(comment string) PaymentSuccessBuilder
}
type paymentSuccessBuilder struct {
properties map[string]any
}
func (b *paymentSuccessBuilder) Price(price float64) interface {
ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
}
} {
b.properties[`$price`] = price
return b
}
func (b *paymentSuccessBuilder) ProductId(productId string) interface {
Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
}
} {
b.properties[`$productId`] = productId
return b
}
func (b *paymentSuccessBuilder) Revenue(revenue float64) interface {
RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
}
} {
b.properties[`$revenue`] = revenue
return b
}
func (b *paymentSuccessBuilder) RevenueType(revenueType string) interface {
Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
}
} {
b.properties[`$revenueType`] = revenueType
return b
}
func (b *paymentSuccessBuilder) Amount(amount float64) interface {
Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
}
} {
b.properties[`amount`] = amount
return b
}
func (b *paymentSuccessBuilder) Domain(domain string) interface {
Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
}
} {
b.properties[`domain`] = domain
return b
}
func (b *paymentSuccessBuilder) Fee(fee float64) interface {
FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
}
} {
b.properties[`fee`] = fee
return b
}
func (b *paymentSuccessBuilder) FeeCoveredBy(feeCoveredBy string) interface {
Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
}
} {
b.properties[`feeCoveredBy`] = feeCoveredBy
return b
}
func (b *paymentSuccessBuilder) Product(product string) interface {
ProductQty(productQty int) PaymentSuccessBuilder
} {
b.properties[`product`] = product
return b
}
func (b *paymentSuccessBuilder) ProductQty(productQty int) PaymentSuccessBuilder {
b.properties[`product_qty`] = productQty
return b
}
func (b *paymentSuccessBuilder) Quantity(quantity int) PaymentSuccessBuilder {
b.properties[`$quantity`] = quantity
return b
}
func (b *paymentSuccessBuilder) Comment(comment string) PaymentSuccessBuilder {
b.properties[`comment`] = comment
return b
}
func (b *paymentSuccessBuilder) Build() PaymentSuccessEvent {
return &paymentSuccessEvent{
newBaseEvent(`paymentSuccess`, b.properties),
}
}
type Ampli struct {
Disabled bool
Client amplitude.Client
mutex sync.RWMutex
}
// Load initializes the Ampli wrapper.
// Call once when your application starts.
func (a *Ampli) Load(options LoadOptions) {
if a.Client != nil {
log.Print("Warn: Ampli is already initialized. Ampli.Load() should be called once at application start up.")
return
}
var apiKey string
switch {
case options.Client.APIKey != "":
apiKey = options.Client.APIKey
case options.Environment != "":
apiKey = APIKey[options.Environment]
default:
apiKey = options.Client.Configuration.APIKey
}
if apiKey == "" && options.Client.Instance == nil {
log.Print("Error: Ampli.Load() requires option.Environment, " +
"and apiKey from either options.Instance.APIKey or APIKey[options.Environment], " +
"or options.Instance.Instance")
}
clientConfig := options.Client.Configuration
if clientConfig.Plan == nil {
clientConfig.Plan = &amplitude.Plan{
Branch: `main`,
Source: `backend`,
Version: `2`,
VersionID: `4fa6851a-4ff0-42f1-b440-8b39f07870e4`,
}
}
if clientConfig.IngestionMetadata == nil {
clientConfig.IngestionMetadata = &amplitude.IngestionMetadata{
SourceName: `go-go-ampli`,
SourceVersion: `2.0.0`,
}
}
if clientConfig.ServerZone == "" {
clientConfig.ServerZone = ServerZoneEU
}
if options.Client.Instance != nil {
a.Client = options.Client.Instance
} else {
clientConfig.APIKey = apiKey
a.Client = amplitude.NewClient(clientConfig)
}
a.mutex.Lock()
a.Disabled = options.Disabled
a.mutex.Unlock()
}
// InitializedAndEnabled checks if Ampli is initialized and enabled.
func (a *Ampli) InitializedAndEnabled() bool {
if a.Client == nil {
log.Print("Error: Ampli is not yet initialized. Have you called Ampli.Load() on app start?")
return false
}
a.mutex.RLock()
defer a.mutex.RUnlock()
return !a.Disabled
}
func (a *Ampli) setUserID(userID string, eventOptions *EventOptions) {
if userID != "" {
eventOptions.UserID = userID
}
}
// Track tracks an event.
func (a *Ampli) Track(userID string, event Event, eventOptions ...EventOptions) {
if !a.InitializedAndEnabled() {
return
}
var options EventOptions
if len(eventOptions) > 0 {
options = eventOptions[0]
}
a.setUserID(userID, &options)
baseEvent := event.ToAmplitudeEvent()
baseEvent.EventOptions = options
a.Client.Track(baseEvent)
}
// Identify identifies a user and set user properties.
func (a *Ampli) Identify(userID string, eventOptions ...EventOptions) {
identify := newBaseEvent(IdentifyEventType, nil)
a.Track(userID, identify, eventOptions...)
}
// Flush flushes events waiting in buffer.
func (a *Ampli) Flush() {
if !a.InitializedAndEnabled() {
return
}
a.Client.Flush()
}
// Shutdown disables and shutdowns Ampli Instance.
func (a *Ampli) Shutdown() {
if !a.InitializedAndEnabled() {
return
}
a.mutex.Lock()
a.Disabled = true
a.mutex.Unlock()
a.Client.Shutdown()
}
func (a *Ampli) EmailOpened(userID string, event EmailOpenedEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) EmailSent(userID string, event EmailSentEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) PaymentFailed(userID string, event PaymentFailedEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}
func (a *Ampli) PaymentSuccess(userID string, event PaymentSuccessEvent, eventOptions ...EventOptions) {
a.Track(userID, event, eventOptions...)
}

View File

@@ -0,0 +1,146 @@
package apiimp
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/notification/interface/services/amplitude"
"github.com/tech/sendico/notification/interface/services/notification"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type Microservices = []mservice.MicroService
type APIImp struct {
logger mlogger.Logger
db db.Factory
localizer localizer.Localizer
domain domainprovider.DomainProvider
config *api.Config
services Microservices
debug bool
mw *Middleware
}
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) Localizer() localizer.Localizer {
return a.localizer
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() messaging.Register {
return a.mw
}
func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, amplitude.Create)
srvf = append(srvf, notification.Create)
for _, v := range srvf {
err := a.addMicroservice(v)
if err != nil {
return err
}
}
a.mw.SetStatus("ok")
return nil
}
func (a *APIImp) Finish(ctx context.Context) error {
a.mw.SetStatus("deactivating")
a.mw.Finish()
var lastError error
// stop services in the reverse order
for i := len(a.services) - 1; i >= 0; i-- {
err := (a.services[i]).Finish(ctx)
if err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err),
zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down",
zap.String("service_name", (a.services[i]).Name()))
}
}
return lastError
}
func (a *APIImp) Name() string {
return "api"
}
func CreateAPI(logger mlogger.Logger, config *api.Config, l localizer.Localizer, db db.Factory, router *chi.Mux, debug bool) (mservice.MicroService, error) {
p := new(APIImp)
p.logger = logger.Named("api")
p.debug = debug
p.config = config
p.db = db
p.localizer = l
var err error
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
p.logger.Error("Failed to initizlize domain provider")
return nil, err
}
odb, err := db.NewOrganizationDB()
if err != nil {
p.logger.Warn("Failed to create organization database", zap.Error(err))
return nil, err
}
if p.mw, err = CreateMiddleware(logger, odb, router, config.Mw, debug); err != nil {
p.logger.Warn("Failed to create middleware", zap.Error(err))
return nil, err
}
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}

View File

@@ -0,0 +1,42 @@
package apiimp
import "github.com/tech/sendico/pkg/messaging"
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
type SignatureConf struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type Signature struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type MessagingConfig struct {
BufferSize int `yaml:"buffer_size"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Messaging messaging.Config `yaml:"message_broker"`
}
type MapClaims = map[string]any

View File

@@ -0,0 +1,59 @@
package apiimp
import (
"os"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/middleware"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
health routers.Health
messaging routers.Messaging
}
func (mw *Middleware) Consumer(processor notifications.EnvelopeProcessor) error {
return mw.messaging.Consumer(processor)
}
func (mw *Middleware) Producer() messaging.Producer {
return mw.messaging.Producer()
}
func (mw *Middleware) Finish() {
mw.messaging.Finish()
mw.health.Finish()
}
func (mw *Middleware) SetStatus(status health.ServiceStatus) {
mw.health.SetStatus(status)
}
func CreateMiddleware(logger mlogger.Logger, db organization.DB, router *chi.Mux, config *middleware.Config, debug bool) (*Middleware, error) {
p := &Middleware{
logger: logger.Named("middleware"),
router: router,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
var err error
if p.messaging, err = routers.NewMessagingRouter(logger, &config.Messaging); err != nil {
p.logger.Error("Failed to create messaging router", zap.Error(err))
return nil, err
}
if p.health, err = routers.NewHealthRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,27 @@
package appversion
import (
"github.com/tech/sendico/pkg/version"
vf "github.com/tech/sendico/pkg/version/factory"
)
// Build information. Populated at build-time.
var (
Version string
Revision string
Branch string
BuildUser string
BuildDate string
)
func Create() version.Printer {
vi := version.Info{
Program: "MeetX Connectica Notification Service",
Revision: Revision,
Branch: Branch,
BuildUser: BuildUser,
BuildDate: BuildDate,
Version: Version,
}
return vf.Create(&vi)
}

View File

@@ -0,0 +1,151 @@
package lclrimp
import (
"encoding/json"
"path"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mutil/fr"
"github.com/nicksnyder/go-i18n/v2/i18n"
"go.uber.org/zap"
"golang.org/x/text/language"
)
type Lang struct {
bundle *i18n.Bundle
localizer *i18n.Localizer
}
type Localizers = map[string]Lang
type Localizer struct {
logger mlogger.Logger
l9rs Localizers
support string
serviceName string
}
type Config struct {
Path string `yaml:"path"`
Langs []string `yaml:"languages"`
Support string `yaml:"support"`
ServiceName string `yaml:"service_name"`
}
func loadBundleLocalization(logger mlogger.Logger, bundle *i18n.Bundle, localizationPath string) error {
b, err := fr.ReadFile(logger, localizationPath)
if err != nil {
logger.Error("Failed to read localization", zap.Error(err), zap.String("localization_path", localizationPath))
return err
}
_, err = bundle.ParseMessageFileBytes(b, localizationPath)
if err != nil {
logger.Error("Failed to parse localization", zap.Error(err), zap.String("localization_path", localizationPath))
return err
}
return err
}
func loadLocalizations(logger mlogger.Logger, source string) (*i18n.Bundle, error) {
bundle := i18n.NewBundle(language.English)
// Register a json unmarshal function for i18n bundle.
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Load translations from json files for non-default languages.
err := loadBundleLocalization(logger, bundle, source)
if err != nil {
// will not log error once again, just return nil
return nil, err
}
return bundle, nil
}
func newLang(logger mlogger.Logger, language string, source string) (*Lang, error) {
var lang Lang
var err error
lang.bundle, err = loadLocalizations(logger, source)
if err != nil {
logger.Error("Failed to install language bundle", zap.Error(err),
zap.String("language", language), zap.String("source", source))
return nil, err
}
lang.localizer = i18n.NewLocalizer(lang.bundle, language)
if lang.localizer != nil {
logger.Info("Installed language bundle",
zap.String("language", language), zap.String("source", source))
} else {
logger.Error("Failed to install language bundle", zap.String("language", language), zap.String("source", source))
return nil, merrors.Internal("failed_to_load_localization")
}
return &lang, nil
}
func prepareLocalizers(logger mlogger.Logger, conf *Config) (Localizers, error) {
localizers := make(Localizers)
for _, lang := range conf.Langs {
path := path.Join(conf.Path, lang+".json")
l, err := newLang(logger, lang, path)
if err != nil {
logger.Error("Failed to load localization", zap.Error(err), zap.String("language", lang), zap.String("source", path))
return localizers, err
}
localizers[lang] = *l
}
return localizers, nil
}
func (loc *Localizer) LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error) {
lclzr, found := loc.l9rs[lang]
if !found {
loc.logger.Info("Language not found, falling back to en", zap.String("message_id", id), zap.String("language", lang))
lclzr = loc.l9rs["en"]
}
config := i18n.LocalizeConfig{
MessageID: id,
TemplateData: templateData,
PluralCount: ctr,
}
localized, err := lclzr.localizer.Localize(&config)
if err != nil {
loc.logger.Warn("Failed to localize string", zap.Error(err), zap.String("message_id", id), zap.String("language", lang))
}
return localized, err
}
func (loc *Localizer) LocalizeString(id string, lang string) (string, error) {
return loc.LocalizeTemplate(id, nil, nil, lang)
}
func (loc *Localizer) ServiceName() string {
return loc.serviceName
}
func (loc *Localizer) SupportMail() string {
return loc.support
}
// NewConnection creates a new database connection
func CreateLocalizer(logger mlogger.Logger, config *Config) (*Localizer, error) {
p := new(Localizer)
p.logger = logger.Named("localizer")
var err error
p.l9rs, err = prepareLocalizers(p.logger, config)
if err != nil {
logger.Warn("Failed to create localizer", zap.Error(err))
return nil, err
}
p.serviceName = config.ServiceName
p.support = config.Support
logger.Info("Localizer is up", zap.String("service_name", p.serviceName), zap.String("support", p.support))
return p, nil
}

View File

@@ -0,0 +1,45 @@
package ampliimp
import (
"context"
"os"
"github.com/amplitude/analytics-go/amplitude"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/internal/ampli"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type AmplitudeAPI struct {
logger mlogger.Logger
}
func (a *AmplitudeAPI) Name() mservice.Type {
return "amplitude"
}
func (a *AmplitudeAPI) Finish(_ context.Context) error {
ampli.Instance.Flush()
return nil
}
func CreateAPI(a api.API) (*AmplitudeAPI, error) {
p := new(AmplitudeAPI)
p.logger = a.Logger().Named(p.Name())
env := os.Getenv(a.Config().Amplitude.Environment)
ampli.Instance.Load(ampli.LoadOptions{
Environment: ampli.EnvironmentProfeetips,
Client: ampli.LoadClientOptions{
Configuration: amplitude.Config{
Logger: p.logger.Named("ampli").Sugar(),
ServerZone: ampli.ServerZoneEU,
},
},
})
p.logger.Info("Amplitude environment is set", zap.String("ampli_environment", env))
return p, nil
}

View File

@@ -0,0 +1,16 @@
package ampliimp
import (
"context"
"github.com/tech/sendico/notification/internal/ampli"
"github.com/tech/sendico/pkg/model"
)
func (a *AmplitudeAPI) onNotificationSent(_ context.Context, nresult *model.NotificationResult) error {
ampli.Instance.EmailSent(
nresult.UserID,
ampli.EmailSent.Builder().Domain("").EmailType("").Build(),
)
return nil
}

View File

@@ -0,0 +1,128 @@
package serverimp
import (
"context"
"errors"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/notification/interface/api"
"github.com/tech/sendico/notification/interface/api/localizer"
apiimip "github.com/tech/sendico/notification/internal/api"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
mduration "github.com/tech/sendico/pkg/mutil/duration"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
type httpServerConf struct {
ListenAddress string `yaml:"listen_address"`
ReadHeaderTimeout int `yaml:"read_header_timeout"`
ShutdownTimeout int `yaml:"shutdown_timeout"`
}
// Config represents the server configuration
type Config struct {
API *api.Config `yaml:"api"`
DB *db.Config `yaml:"database"`
Localizer *localizer.Config `yaml:"localizer"`
HTTPServer *httpServerConf `yaml:"http_server"`
}
// Instance represents an instance of the server
type Imp struct {
logger mlogger.Logger
api mservice.MicroService
config *Config
db db.Factory
l localizer.Localizer
httpServer *http.Server
debug bool
file string
}
// Shutdown stops the server
func (i *Imp) Shutdown() {
// Shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), mduration.Param2Duration(i.config.HTTPServer.ShutdownTimeout, time.Second))
i.logger.Info("Shutting HTTP server down...")
if err := i.httpServer.Shutdown(ctx); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Warn("Failed to shutdown HTTP server gracefully", zap.Error(err))
cancel()
os.Exit(1)
}
}
cancel()
}
func (i *Imp) Run() error {
if err := i.httpServer.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
i.logger.Error("HTTP Server stopped unexpectedly", zap.Error(err))
}
}
i.logger.Info("HTTP Server stopped")
if err := i.api.Finish(context.Background()); err != nil {
i.logger.Warn("Error when finishing service", zap.Error(err))
}
i.db.CloseConnection()
return nil
}
// Start starts the server
func (i *Imp) Start() error {
i.logger.Info("Starting...", zap.String("config_file", i.file), zap.Bool("debug_mode", i.debug))
// Load configuration file
data, err := os.ReadFile(i.file)
if err != nil {
i.logger.Error("Could not load configuration", zap.Error(err), zap.String("config_file", i.file))
return err
}
if err = yaml.Unmarshal(data, &i.config); err != nil {
i.logger.Error("Failed to parse configuration", zap.Error(err))
return err
}
if i.db, err = db.NewConnection(i.logger, i.config.DB); err != nil {
i.logger.Error("Could not open database connection", zap.Error(err))
return err
}
if i.l, err = localizer.CreateLocalizer(i.logger, i.config.Localizer); err != nil {
i.logger.Error("Failed to create localizer", zap.Error(err))
return err
}
router := chi.NewRouter()
if i.api, err = apiimip.CreateAPI(i.logger, i.config.API, i.l, i.db, router, i.debug); err != nil {
i.logger.Error("Failed to create API instance", zap.Error(err))
return err
}
// Startup the HTTP Server in a way that we can gracefully shut it down again
i.httpServer = &http.Server{
Addr: i.config.HTTPServer.ListenAddress,
Handler: router,
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
}
return i.Run()
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
srv := &Imp{
logger: logger,
debug: debug,
file: file,
}
return srv, nil
}

View File

@@ -0,0 +1,29 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *NotificationAPI) onAccount(context context.Context, account *model.Account) error {
var link string
var err error
if link, err = a.dp.GetFullLink("verify", account.VerifyToken); err != nil {
a.logger.Warn("Failed to generate verification link", zap.Error(err), zap.String("login", account.Login))
return err
}
mr := a.client.MailBuilder().
AddRecipient(account.Name, account.Login).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
SetTemplateID("welcome")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), zap.String("login", account.Login))
return err
}
a.logger.Info("Verification email sent", zap.String("login", account.Login))
return nil
}

View File

@@ -0,0 +1,32 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func (a *NotificationAPI) onInvitation(context context.Context, account *model.Account, invitation *model.Invitation) error {
var link string
var err error
if link, err = a.dp.GetFullLink(mservice.Invitations, invitation.ID.Hex()); err != nil {
a.logger.Warn("Failed to generate invitation link", zap.Error(err), zap.String("email", invitation.Content.Email))
return err
}
mr := a.client.MailBuilder().
AddData("InviterName", account.Name).
AddData("Name", invitation.Content.Name).
AddRecipient(invitation.Content.Name, invitation.Content.Email).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
SetTemplateID("invitation")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), zap.String("email", invitation.Content.Email))
return err
}
a.logger.Info("Invitation email sent", zap.String("to", invitation.Content.Email), zap.String("on_behalf_of", account.Name))
return nil
}

View File

@@ -0,0 +1,70 @@
package mail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/messaging"
nn "github.com/tech/sendico/pkg/messaging/notifications/notification"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
type AmpliMailer struct {
logger mlogger.Logger
producer messaging.Producer
client Client
source string
}
func (am *AmpliMailer) Send(m mmail.MailBuilder) error {
err := am.client.Send(m)
if err != nil {
am.logger.Warn("Failed to send email", zap.Error(err))
}
opResult := model.OperationResult{
IsSuccessful: err == nil,
}
if !opResult.IsSuccessful {
opResult.Error = err.Error()
}
msg, e := m.Build()
if e != nil {
am.logger.Warn("Failed to build message content", zap.Error(e))
return e
}
if er := am.producer.SendMessage(nn.NotificationSent(am.source, &model.NotificationResult{
Channel: "email",
TemplateID: msg.TemplateID(),
Locale: msg.Locale(),
AmpliEvent: model.AmpliEvent{
UserID: "",
},
Result: opResult,
})); er != nil {
am.logger.Warn("Failed to send mailing result", zap.Error(er))
}
return err
}
func (am *AmpliMailer) MailBuilder() mmail.MailBuilder {
return am.client.MailBuilder()
}
func NewAmpliMailer(log mlogger.Logger, sender string, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (*AmpliMailer, error) {
logger := log.Named("ampli")
c, err := createMailClient(logger, producer, l, dp, config)
if err != nil {
logger.Warn("Failed to create mailng driver", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
am := &AmpliMailer{
logger: logger,
client: c,
producer: producer,
source: sender,
}
am.logger.Info("Amplitude wrapper installed")
return am, nil
}

View File

@@ -0,0 +1,54 @@
package mailimp
import (
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
)
type MessageBuilderImp struct {
message *MessageImp
}
func (mb *MessageBuilderImp) SetAccountID(accountID string) mmail.MailBuilder {
mb.message.accountUID = accountID
return mb
}
func (mb *MessageBuilderImp) SetTemplateID(templateID string) mmail.MailBuilder {
mb.message.templateID = templateID
return mb
}
func (mb *MessageBuilderImp) SetLocale(locale string) mmail.MailBuilder {
mb.message.locale = locale
return mb
}
func (mb *MessageBuilderImp) AddButton(link string) mmail.MailBuilder {
mb.message.buttonLink = link
return mb
}
func (mb *MessageBuilderImp) AddRecipient(recipientName, recipient string) mmail.MailBuilder {
mb.message.recipientName = recipientName
mb.message.recipients = append(mb.message.recipients, recipient)
return mb
}
func (mb *MessageBuilderImp) AddData(key, value string) mmail.MailBuilder {
mb.message.parameters[key] = value
return mb
}
func (mb *MessageBuilderImp) Build() (mmail.Message, error) {
if len(mb.message.recipients) == 0 {
return nil, merrors.InvalidArgument("Recipient not set")
}
return mb.message, nil
}
func NewMessageBuilder() *MessageBuilderImp {
return &MessageBuilderImp{
message: createMessageImp(),
}
}

View File

@@ -0,0 +1,251 @@
package mailimp
import (
"errors"
"testing"
"github.com/tech/sendico/pkg/merrors"
)
func TestNewMessageBuilder_CreatesValidBuilder(t *testing.T) {
builder := NewMessageBuilder()
if builder == nil {
t.Fatal("Expected non-nil builder")
}
if builder.message == nil {
t.Fatal("Expected builder to have initialized message")
}
}
func TestMessageBuilder_BuildWithoutRecipient_ReturnsError(t *testing.T) {
builder := NewMessageBuilder()
_, err := builder.Build()
if err == nil {
t.Fatal("Expected error when building without recipient")
}
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Errorf("Expected InvalidArgument error, got: %v", err)
}
}
func TestMessageBuilder_BuildWithRecipient_Success(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddRecipient("John Doe", "john@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg == nil {
t.Fatal("Expected non-nil message")
}
}
func TestMessageBuilder_SetAccountID_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
accountID := "507f1f77bcf86cd799439011"
msg, err := builder.
SetAccountID(accountID).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.AccountID() != accountID {
t.Errorf("Expected AccountID %s, got %s", accountID, msg.AccountID())
}
}
func TestMessageBuilder_SetTemplateID_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
templateID := "welcome"
msg, err := builder.
SetTemplateID(templateID).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.TemplateID() != templateID {
t.Errorf("Expected TemplateID %s, got %s", templateID, msg.TemplateID())
}
}
func TestMessageBuilder_SetLocale_SetsCorrectValue(t *testing.T) {
builder := NewMessageBuilder()
locale := "en-US"
msg, err := builder.
SetLocale(locale).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.Locale() != locale {
t.Errorf("Expected Locale %s, got %s", locale, msg.Locale())
}
}
func TestMessageBuilder_AddRecipient_AddsToRecipientsList(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddRecipient("User One", "user1@example.com").
AddRecipient("User Two", "user2@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
recipients := msg.Recipients()
if len(recipients) != 2 {
t.Fatalf("Expected 2 recipients, got %d", len(recipients))
}
if recipients[0] != "user1@example.com" {
t.Errorf("Expected first recipient to be user1@example.com, got %s", recipients[0])
}
if recipients[1] != "user2@example.com" {
t.Errorf("Expected second recipient to be user2@example.com, got %s", recipients[1])
}
}
func TestMessageBuilder_AddData_AccumulatesParameters(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
AddData("key1", "value1").
AddData("key2", "value2").
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
params := msg.Parameters()
if len(params) != 2 {
t.Fatalf("Expected 2 parameters, got %d", len(params))
}
if params["key1"] != "value1" {
t.Errorf("Expected key1=value1, got %v", params["key1"])
}
if params["key2"] != "value2" {
t.Errorf("Expected key2=value2, got %v", params["key2"])
}
}
func TestMessageBuilder_AddButton_StoresButtonLink(t *testing.T) {
builder := NewMessageBuilder()
buttonLink := "https://example.com/verify"
msg, err := builder.
AddButton(buttonLink).
AddRecipient("Test User", "test@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Button link is internal, but we can verify the message was built successfully
if msg == nil {
t.Fatal("Expected non-nil message with button")
}
}
func TestMessageBuilder_ChainedMethods_SetsAllFields(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
SetAccountID("507f1f77bcf86cd799439011").
SetTemplateID("welcome").
SetLocale("en-US").
AddButton("https://example.com/verify").
AddRecipient("John Doe", "john@example.com").
AddData("name", "John").
AddData("age", "30").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if msg.AccountID() != "507f1f77bcf86cd799439011" {
t.Errorf("AccountID not set correctly")
}
if msg.TemplateID() != "welcome" {
t.Errorf("TemplateID not set correctly")
}
if msg.Locale() != "en-US" {
t.Errorf("Locale not set correctly")
}
if len(msg.Recipients()) != 1 {
t.Errorf("Recipients not set correctly")
}
if len(msg.Parameters()) != 2 {
t.Errorf("Parameters not set correctly")
}
}
func TestMessageBuilder_MultipleBuilds_IndependentMessages(t *testing.T) {
builder1 := NewMessageBuilder()
builder2 := NewMessageBuilder()
msg1, err1 := builder1.
SetTemplateID("template1").
AddRecipient("User 1", "user1@example.com").
Build()
msg2, err2 := builder2.
SetTemplateID("template2").
AddRecipient("User 2", "user2@example.com").
Build()
if err1 != nil || err2 != nil {
t.Fatalf("Unexpected errors: %v, %v", err1, err2)
}
if msg1.TemplateID() == msg2.TemplateID() {
t.Error("Messages should be independent with different template IDs")
}
if msg1.Recipients()[0] == msg2.Recipients()[0] {
t.Error("Messages should be independent with different recipients")
}
}
func TestMessageBuilder_EmptyValues_AreAllowed(t *testing.T) {
builder := NewMessageBuilder()
msg, err := builder.
SetAccountID("").
SetTemplateID("").
SetLocale("").
AddButton("").
AddRecipient("", "user@example.com").
Build()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Empty values should be allowed - business logic validation happens elsewhere
if msg == nil {
t.Fatal("Expected message to be built even with empty values")
}
}

View File

@@ -0,0 +1,150 @@
package mailimp
import (
"maps"
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/mailkey"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/localization"
)
type EmailNotificationTemplate struct {
dp domainprovider.DomainProvider
l localizer.Localizer
data localization.LocData
unsubscribable bool
hasButton bool
}
func (m *EmailNotificationTemplate) AddData(key, value string) {
localization.AddLocData(m.data, key, value)
}
// content:
// Greeting: Welcome, Gregory
// Content: You're receiving this message because you recently signed up for an account.<br><br>Confirm your email address by clicking the button below. This step adds extra security to your business by verifying you own this email.
// LogoLink: link to a logo
// Privacy: Privacy Policy
// PolicyLink: link to a privacy policy
// Unsubscribe: Unsubscribe
// UnsubscribeLink: link to an unsubscribe command
// MessageTitle: message title
func (m *EmailNotificationTemplate) prepareUnsubscribe(msg mmail.Message) error {
var block string
if m.unsubscribable {
var d localization.LocData
unsubscribe, err := m.l.LocalizeString("mail.template.unsubscribe", msg.Locale())
if err != nil {
return err
}
localization.AddLocData(d, "Unsubscribe", unsubscribe)
unsLink, err := m.dp.GetFullLink("account", "unsubscribe", msg.AccountID())
if err != nil {
return err
}
localization.AddLocData(d, "UnsubscribeLink", unsLink)
if block, err = m.l.LocalizeTemplate("mail.template.unsubscribe.block", d, nil, msg.Locale()); err != nil {
return err
}
}
m.AddData("UnsubscribeBlock", block)
return nil
}
func (m *EmailNotificationTemplate) prepareButton(msg mmail.Message) error {
var block string
if m.hasButton {
var err error
if block, err = m.l.LocalizeTemplate("mail.template.btn.block", m.data, nil, msg.Locale()); err != nil {
return err
}
}
m.AddData("ButtonBlock", block)
return nil
}
func (m *EmailNotificationTemplate) SignatureData(msg mmail.Message, content, subj string) (string, error) {
m.AddData("Content", content)
m.AddData("MessageTitle", subj)
logoLink, err := m.dp.GetAPILink("logo", msg.AccountID(), msg.TemplateID())
if err != nil {
return "", err
}
m.AddData("LogoLink", logoLink)
privacy, err := m.l.LocalizeString("mail.template.privacy", msg.Locale())
if err != nil {
return "", err
}
m.AddData("Privacy", privacy)
ppLink, err := m.dp.GetFullLink("/privacy-policy")
if err != nil {
return "", err
}
m.AddData("PolicyLink", ppLink)
if err := m.prepareButton(msg); err != nil {
return "", err
}
if err := m.prepareUnsubscribe(msg); err != nil {
return "", err
}
return m.l.LocalizeTemplate("mail.template.one_button", m.data, nil, msg.Locale())
}
func (m *EmailNotificationTemplate) putOnHTMLTemplate(msg mmail.Message, content, subj string) (string, error) {
greeting, err := m.l.LocalizeTemplate(mailkey.Get(msg.TemplateID(), "greeting"), m.data, nil, msg.Locale())
if err != nil {
return "", err
}
m.AddData("Greeting", greeting)
return m.SignatureData(msg, content, subj)
}
func (m *EmailNotificationTemplate) Build(msg mmail.Message) (string, error) {
if m.data != nil {
m.data["ServiceName"] = m.l.ServiceName()
m.data["SupportMail"] = m.l.SupportMail()
var err error
if m.data["ServiceOwner"], err = m.l.LocalizeString("service.owner", msg.Locale()); err != nil {
return "", err
}
if m.data["OwnerAddress"], err = m.l.LocalizeString("service.address", msg.Locale()); err != nil {
return "", err
}
if m.data["OwnerPhone"], err = m.l.LocalizeString("service.phone", msg.Locale()); err != nil {
return "", err
}
maps.Copy(m.data, msg.Parameters())
}
content, err := mailkey.Body(m.l, m.data, msg.TemplateID(), msg.Locale())
if err != nil {
return "", err
}
subject, err := mailkey.Subject(m.l, m.data, msg.TemplateID(), msg.Locale())
if err != nil {
return "", err
}
return m.putOnHTMLTemplate(msg, content, subject)
}
func (t *EmailNotificationTemplate) SetUnsubscribable(isUnsubscribable bool) {
t.unsubscribable = isUnsubscribable
}
func (t *EmailNotificationTemplate) SetButton(hasButton bool) {
t.hasButton = hasButton
}
func NewEmailNotification(l localizer.Localizer, dp domainprovider.DomainProvider) *EmailNotificationTemplate {
p := &EmailNotificationTemplate{
dp: dp,
l: l,
data: localization.LocData{},
}
p.unsubscribable = false
p.hasButton = false
return p
}

View File

@@ -0,0 +1,56 @@
package mailimp
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type MessageImp struct {
templateID string
accountUID string
locale string
recipients []string
recipientName string
buttonLink string
parameters map[string]any
}
func (m *MessageImp) TemplateID() string {
return m.templateID
}
func (m *MessageImp) Locale() string {
return m.locale
}
func (m *MessageImp) AccountID() string {
return m.accountUID
}
func (m *MessageImp) Recipients() []string {
return m.recipients
}
func (m *MessageImp) Parameters() map[string]any {
return m.parameters
}
func (m *MessageImp) Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error) {
if len(m.buttonLink) == 0 {
return NewEmailNotification(l, dp).Build(m)
}
page := NewOneButton(l, dp)
buttonLabel, err := l.LocalizeString("btn."+m.TemplateID(), m.Locale())
if err != nil {
return "", err
}
page.AddButton(buttonLabel, m.buttonLink)
return page.Build(m)
}
func createMessageImp() *MessageImp {
return &MessageImp{
parameters: map[string]any{},
recipients: []string{},
}
}

View File

@@ -0,0 +1,256 @@
package mailimp
import (
"fmt"
"testing"
)
// Mock implementations for testing
type mockLocalizer struct {
localizeTemplateFunc func(id string, templateData, ctr any, lang string) (string, error)
localizeStringFunc func(id, lang string) (string, error)
serviceName string
supportMail string
}
func (m *mockLocalizer) LocalizeTemplate(id string, templateData, ctr any, lang string) (string, error) {
if m.localizeTemplateFunc != nil {
return m.localizeTemplateFunc(id, templateData, ctr, lang)
}
// Return a simple HTML template for testing
return fmt.Sprintf("<html><body>Template: %s</body></html>", id), nil
}
func (m *mockLocalizer) LocalizeString(id, lang string) (string, error) {
if m.localizeStringFunc != nil {
return m.localizeStringFunc(id, lang)
}
return fmt.Sprintf("string:%s", id), nil
}
func (m *mockLocalizer) ServiceName() string {
if m.serviceName != "" {
return m.serviceName
}
return "TestService"
}
func (m *mockLocalizer) SupportMail() string {
if m.supportMail != "" {
return m.supportMail
}
return "support@test.com"
}
type mockDomainProvider struct {
getFullLinkFunc func(linkElem ...string) (string, error)
getAPILinkFunc func(linkElem ...string) (string, error)
}
func (m *mockDomainProvider) GetFullLink(linkElem ...string) (string, error) {
if m.getFullLinkFunc != nil {
return m.getFullLinkFunc(linkElem...)
}
return "https://example.com/link", nil
}
func (m *mockDomainProvider) GetAPILink(linkElem ...string) (string, error) {
if m.getAPILinkFunc != nil {
return m.getAPILinkFunc(linkElem...)
}
return "https://api.example.com/link", nil
}
// Tests
func TestMessageImp_TemplateID_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
if msg.TemplateID() != "welcome" {
t.Errorf("Expected templateID 'welcome', got '%s'", msg.TemplateID())
}
}
func TestMessageImp_Locale_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.locale = "en-US"
if msg.Locale() != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", msg.Locale())
}
}
func TestMessageImp_AccountID_ReturnsCorrectValue(t *testing.T) {
msg := createMessageImp()
msg.accountUID = "507f1f77bcf86cd799439011"
if msg.AccountID() != "507f1f77bcf86cd799439011" {
t.Errorf("Expected accountUID '507f1f77bcf86cd799439011', got '%s'", msg.AccountID())
}
}
func TestMessageImp_Recipients_ReturnsCorrectList(t *testing.T) {
msg := createMessageImp()
msg.recipients = []string{"user1@example.com", "user2@example.com"}
recipients := msg.Recipients()
if len(recipients) != 2 {
t.Fatalf("Expected 2 recipients, got %d", len(recipients))
}
if recipients[0] != "user1@example.com" {
t.Errorf("Expected first recipient 'user1@example.com', got '%s'", recipients[0])
}
if recipients[1] != "user2@example.com" {
t.Errorf("Expected second recipient 'user2@example.com', got '%s'", recipients[1])
}
}
func TestMessageImp_Parameters_ReturnsCorrectMap(t *testing.T) {
msg := createMessageImp()
msg.parameters["key1"] = "value1"
msg.parameters["key2"] = "value2"
params := msg.Parameters()
if len(params) != 2 {
t.Fatalf("Expected 2 parameters, got %d", len(params))
}
if params["key1"] != "value1" {
t.Errorf("Expected key1='value1', got '%v'", params["key1"])
}
if params["key2"] != "value2" {
t.Errorf("Expected key2='value2', got '%v'", params["key2"])
}
}
func TestMessageImp_Body_WithButton_CallsOneButtonTemplate(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
msg.locale = "en-US"
msg.buttonLink = "https://example.com/verify"
mockLoc := &mockLocalizer{
localizeStringFunc: func(id, lang string) (string, error) {
// Mock all localization calls that might occur
switch id {
case "btn.welcome":
return "Verify Account", nil
case "service.owner", "service.name":
return "Test Service", nil
default:
return fmt.Sprintf("localized:%s", id), nil
}
},
}
mockDP := &mockDomainProvider{}
body, err := msg.Body(mockLoc, mockDP)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if body == "" {
t.Error("Expected non-empty body")
}
// Body should be HTML from one-button template
// We can't test exact content without knowing template implementation,
// but we can verify it succeeded
}
func TestMessageImp_Body_WithoutButton_CallsEmailNotification(t *testing.T) {
msg := createMessageImp()
msg.templateID = "notification"
msg.locale = "en-US"
msg.buttonLink = "" // No button
mockLoc := &mockLocalizer{}
mockDP := &mockDomainProvider{}
body, err := msg.Body(mockLoc, mockDP)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if body == "" {
t.Error("Expected non-empty body")
}
}
func TestMessageImp_Body_LocalizationError_ReturnsError(t *testing.T) {
msg := createMessageImp()
msg.templateID = "welcome"
msg.locale = "invalid-locale"
msg.buttonLink = "https://example.com/verify"
mockLoc := &mockLocalizer{
localizeStringFunc: func(id, lang string) (string, error) {
return "", fmt.Errorf("localization failed for lang: %s", lang)
},
}
mockDP := &mockDomainProvider{}
_, err := msg.Body(mockLoc, mockDP)
if err == nil {
t.Error("Expected error from localization failure")
}
}
func TestCreateMessageImp_InitializesEmptyCollections(t *testing.T) {
msg := createMessageImp()
if msg.parameters == nil {
t.Error("Expected parameters map to be initialized")
}
if msg.recipients == nil {
t.Error("Expected recipients slice to be initialized")
}
if len(msg.parameters) != 0 {
t.Error("Expected parameters map to be empty")
}
if len(msg.recipients) != 0 {
t.Error("Expected recipients slice to be empty")
}
}
func TestMessageImp_MultipleParameterTypes_StoresCorrectly(t *testing.T) {
msg := createMessageImp()
msg.parameters["string"] = "value"
msg.parameters["number"] = 42
msg.parameters["bool"] = true
params := msg.Parameters()
if params["string"] != "value" {
t.Error("String parameter not stored correctly")
}
if params["number"] != 42 {
t.Error("Number parameter not stored correctly")
}
if params["bool"] != true {
t.Error("Boolean parameter not stored correctly")
}
}
func TestMessageImp_EmptyTemplateID_AllowedByGetter(t *testing.T) {
msg := createMessageImp()
msg.templateID = ""
// Should not panic or error
result := msg.TemplateID()
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}
func TestMessageImp_EmptyLocale_AllowedByGetter(t *testing.T) {
msg := createMessageImp()
msg.locale = ""
// Should not panic or error
result := msg.Locale()
if result != "" {
t.Errorf("Expected empty string, got '%s'", result)
}
}

View File

@@ -0,0 +1,33 @@
package mailimp
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type OneButtonTemplate struct {
EmailNotificationTemplate
}
func (b *OneButtonTemplate) AddButtonText(text string) {
b.AddData("ButtonText", text)
}
func (b *OneButtonTemplate) AddButtonLink(link string) {
b.AddData("ButtonLink", link)
}
func (b *OneButtonTemplate) AddButton(text, link string) {
b.AddButtonText(text)
b.AddButtonLink(link)
}
func NewOneButton(l localizer.Localizer, dp domainprovider.DomainProvider) *OneButtonTemplate {
p := &OneButtonTemplate{
EmailNotificationTemplate: *NewEmailNotification(l, dp),
}
p.SetUnsubscribable(false)
p.SetButton(true)
return p
}

View File

@@ -0,0 +1,29 @@
package mailimp
import (
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
b "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
)
type Dummy struct {
logger mlogger.Logger
}
func (d *Dummy) Send(_ b.MailBuilder) error {
d.logger.Warn("Unexpected request to send email")
return merrors.NotImplemented("MailDummy::Send")
}
func (d *Dummy) MailBuilder() b.MailBuilder {
return mb.NewMessageBuilder()
}
func NewDummy(logger mlogger.Logger) (*Dummy, error) {
d := &Dummy{
logger: logger.Named("dummy"),
}
d.logger.Info("Mailer installed")
return d, nil
}

View File

@@ -0,0 +1,98 @@
package mailimp
import (
"errors"
"testing"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger/factory"
)
func TestNewDummy_CreatesValidClient(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Unexpected error creating dummy client: %v", err)
}
if dummy == nil {
t.Fatal("Expected non-nil dummy client")
}
}
func TestDummy_Send_ReturnsNotImplementedError(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := mb.NewMessageBuilder()
err = dummy.Send(builder)
if err == nil {
t.Fatal("Expected error when calling Send on dummy client")
}
if !errors.Is(err, merrors.ErrNotImplemented) {
t.Errorf("Expected NotImplemented error, got: %v", err)
}
}
func TestDummy_MailBuilder_ReturnsValidBuilder(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := dummy.MailBuilder()
if builder == nil {
t.Fatal("Expected non-nil mail builder")
}
}
func TestDummy_MailBuilder_CanBuildMessage(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder := dummy.MailBuilder()
msg, err := builder.
AddRecipient("Test User", "test@example.com").
SetTemplateID("welcome").
Build()
if err != nil {
t.Fatalf("Unexpected error building message: %v", err)
}
if msg == nil {
t.Fatal("Expected non-nil message")
}
}
func TestDummy_MultipleSendCalls_AllReturnError(t *testing.T) {
logger := mlogger.NewLogger(true)
dummy, err := NewDummy(logger)
if err != nil {
t.Fatalf("Failed to create dummy client: %v", err)
}
builder1 := dummy.MailBuilder()
builder2 := dummy.MailBuilder()
err1 := dummy.Send(builder1)
err2 := dummy.Send(builder2)
if err1 == nil || err2 == nil {
t.Error("Expected all Send calls to return errors")
}
if !errors.Is(err1, merrors.ErrNotImplemented) || !errors.Is(err2, merrors.ErrNotImplemented) {
t.Error("Expected all errors to be NotImplemented")
}
}

View File

@@ -0,0 +1,174 @@
package mailimp
import (
"crypto/tls"
"time"
"github.com/tech/sendico/notification/interface/api/localizer"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
"github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/mailkey"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
mutil "github.com/tech/sendico/pkg/mutil/config"
mduration "github.com/tech/sendico/pkg/mutil/duration"
mail "github.com/xhit/go-simple-mail/v2"
"go.uber.org/zap"
)
// Client implements a mail client
type Client struct {
logger mlogger.Logger
server *mail.SMTPServer
client *mail.SMTPClient
from string
l localizer.Localizer
dp domainprovider.DomainProvider
}
// Config represents the mail configuration
type GSMConfig struct {
Username *string `mapstructure:"username,omitempty" yaml:"username,omitempty"`
UsernameEnv *string `mapstructure:"username_env,omitempty" yaml:"username_env,omitempty"`
Password *string `mapstructure:"password" yaml:"password"`
PasswordEnv *string `mapstructure:"password_env" yaml:"password_env"`
Host string `mapstructure:"host" yaml:"host"`
Port int `mapstructure:"port" yaml:"port"`
From string `mapstructure:"from" yaml:"from"`
TimeOut int `mapstructure:"network_timeout" yaml:"network_timeout"`
}
func (c *Client) sendImp(m mmail.Message, msg *mail.Email) error {
err := msg.Send(c.client)
if err != nil {
c.logger.Warn("Error sending email", zap.Error(err), zap.String("template_id", m.TemplateID()), zap.Strings("recipients", msg.GetRecipients()))
} else {
c.logger.Info("Email sent", zap.Strings("recipients", msg.GetRecipients()), zap.String("template_id", m.TemplateID()))
}
// TODO: add amplitude notification
return err
}
// Send sends an email message to the provided address and with the provided subject
func (c *Client) Send(r mmail.MailBuilder) error {
// New email simple html with inline and CC
r.AddData("ServiceName", c.l.ServiceName()).AddData("SupportMail", c.l.SupportMail())
m, err := r.Build()
if err != nil {
c.logger.Warn("Failed to build message", zap.Error(err))
return err
}
body, err := m.Body(c.l, c.dp)
if err != nil {
c.logger.Warn("Failed to build message body", zap.Error(err))
return err
}
if (len(body) == 0) || (len(m.Recipients()) == 0) {
c.logger.Warn("Malformed messge", zap.String("template_id", m.TemplateID()),
zap.String("locale", m.Locale()), zap.Strings("recipients", m.Recipients()),
zap.Int("body_size", len(body)))
return merrors.InvalidArgument("malformed message")
}
subj, err := mailkey.Subject(c.l, m.Parameters(), m.TemplateID(), m.Locale())
if err != nil {
c.logger.Warn("Failed to localize subject", zap.Error(err), zap.String("template_id", m.TemplateID()),
zap.String("locale", m.Locale()), zap.Strings("recipients", m.Recipients()),
zap.Int("body_size", len(body)))
return err
}
msg := mail.NewMSG()
msg.SetFrom(c.from).
AddTo(m.Recipients()...).
SetSubject(subj).
SetBody(mail.TextHTML, body)
// Call Send and pass the client
if err = c.sendImp(m, msg); err != nil {
c.logger.Info("Failed to send an email, attempting to reconnect...",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
c.client = nil
c.client, err = c.server.Connect()
if err != nil {
c.logger.Warn("Failed to reconnect mail client",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
return err
}
c.logger.Info("Connection has been successfully restored",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
err = c.sendImp(m, msg)
if err != nil {
c.logger.Warn("Failed to send message after mail client recreation",
zap.Error(err),
zap.String("template", m.TemplateID()), zap.Strings("addresses", m.Recipients()), zap.String("locale", m.Locale()))
return err
}
}
return err
}
func (c *Client) MailBuilder() mmail.MailBuilder {
return mb.NewMessageBuilder()
}
// NewClient return a new mail
func NewClient(logger mlogger.Logger, l localizer.Localizer, dp domainprovider.DomainProvider, config *GSMConfig) *Client {
smtpServer := mail.NewSMTPClient()
// SMTP Server
smtpServer.Host = config.Host
if config.Port < 1 {
logger.Warn("Invalid mail client port configuration, defaulting to 465", zap.Int("port", config.Port))
config.Port = 465
}
smtpServer.Port = config.Port
smtpServer.Username = mutil.GetConfigValue(logger, "username", "username_env", config.Username, config.UsernameEnv)
smtpServer.Password = mutil.GetConfigValue(logger, "password", "password_env", config.Password, config.PasswordEnv)
smtpServer.Encryption = mail.EncryptionSSL
// Since v2.3.0 you can specified authentication type:
// - PLAIN (default)
// - LOGIN
// - CRAM-MD5
// server.Authentication = mail.AuthPlain
// Variable to keep alive connection
smtpServer.KeepAlive = true
// Timeout for connect to SMTP Server
smtpServer.ConnectTimeout = mduration.Param2Duration(config.TimeOut, time.Second)
// Timeout for send the data and wait respond
smtpServer.SendTimeout = mduration.Param2Duration(config.TimeOut, time.Second)
// Set TLSConfig to provide custom TLS configuration. For example,
// to skip TLS verification (useful for testing):
smtpServer.TLSConfig = &tls.Config{InsecureSkipVerify: true}
// SMTP client
lg := logger.Named("client")
smtpClient, err := smtpServer.Connect()
if err != nil {
lg.Warn("Failed to connect", zap.Error(err))
} else {
lg.Info("Connected successfully", zap.String("username", smtpServer.Username), zap.String("host", config.Host))
}
from := config.From + " <" + smtpServer.Username + ">"
return &Client{
logger: lg,
server: smtpServer,
client: smtpClient,
from: from,
l: l,
dp: dp,
}
}

View File

@@ -0,0 +1,15 @@
package mailkey
import "github.com/tech/sendico/notification/interface/api/localizer"
func Get(template, part string) string {
return "mail." + template + "." + part
}
func Subject(l localizer.Localizer, data map[string]any, templateID, locale string) (string, error) {
return l.LocalizeTemplate(Get(templateID, "subj"), data, nil, locale)
}
func Body(l localizer.Localizer, data map[string]any, templateID, locale string) (string, error) {
return l.LocalizeTemplate(Get(templateID, "body"), data, nil, locale)
}

View File

@@ -0,0 +1,104 @@
package mailimp
import (
"net/http"
"os"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal/builder"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
"go.uber.org/zap"
)
type KeysConfig struct {
Email string `yaml:"email"`
Name string `yaml:"name"`
URL string `yaml:"url"`
ID string `yaml:"id"`
}
type Sender struct {
Address string `yaml:"address"`
Name string `yaml:"name"`
}
type SGEmailConfig struct {
Sender Sender `yaml:"sender"`
}
type SendGridConfig struct {
APIKeyEnv string `yaml:"api_key_env"`
Email SGEmailConfig `yaml:"email"`
Keys KeysConfig `yaml:"keys"`
}
type SendGridNotifier struct {
logger mlogger.Logger
client *sendgrid.Client
config *SendGridConfig
producer messaging.Producer
}
func (sg *SendGridNotifier) Send(mb mmail.MailBuilder) error {
m := mail.NewV3Mail()
e := mail.NewEmail(sg.config.Email.Sender.Name, sg.config.Email.Sender.Address)
m.SetFrom(e)
task, err := mb.Build()
if err != nil {
sg.logger.Warn("Failed to build message", zap.Error(err))
return err
}
m.SetTemplateID(task.TemplateID())
p := mail.NewPersonalization()
for _, recipient := range task.Recipients() {
p.AddTos(mail.NewEmail(recipient, recipient))
}
for k, v := range task.Parameters() {
p.SetDynamicTemplateData(k, v)
}
m.AddPersonalizations(p)
response, err := sg.client.Send(m)
if err != nil {
sg.logger.Warn("Failed to send email", zap.Error(err), zap.Any("task", &task))
return err
}
if (response.StatusCode != http.StatusOK) && (response.StatusCode != http.StatusAccepted) {
sg.logger.Warn("Unexpected SendGrid sresponse", zap.Int("status_code", response.StatusCode),
zap.String("sresponse", response.Body), zap.Any("task", &task))
return merrors.Internal("email_notification_not_sent")
}
sg.logger.Info("Email sent successfully", zap.Strings("recipients", task.Recipients()), zap.String("template_id", task.TemplateID()))
// if err = sg.producer.SendMessage(model.NewNotification(model.NTEmail, model.NAComplete), &task); err != nil {
// sg.logger.Warn("Failed to send email statistics", zap.Error(err), zap.Strings("recipients", task.Recipients), zap.String("template_id", task.TemplateID))
// }
return nil
}
func (sg *SendGridNotifier) MailBuilder() mmail.MailBuilder {
return mb.NewMessageBuilder()
}
func NewSendGridNotifier(logger mlogger.Logger, producer messaging.Producer, config *SendGridConfig) (*SendGridNotifier, error) {
apiKey := os.Getenv(config.APIKeyEnv)
if apiKey == "" {
logger.Warn("No SendGrid API key")
return nil, merrors.NoData("No SendGrid API key")
}
return &SendGridNotifier{
logger: logger.Named("sendgrid"),
client: sendgrid.NewSendClient(apiKey),
config: config,
producer: producer,
}, nil
}

View File

@@ -0,0 +1,53 @@
package mail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
notification "github.com/tech/sendico/notification/interface/services/notification/config"
mi "github.com/tech/sendico/notification/internal/server/notificationimp/mail/internal"
mb "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/mitchellh/mapstructure"
"go.uber.org/zap"
)
type Client interface {
Send(r mb.MailBuilder) error
MailBuilder() mb.MailBuilder
}
type Config = notification.Config
func createMailClient(logger mlogger.Logger, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (Client, error) {
if len(config.Driver) == 0 {
return nil, merrors.InvalidArgument("Mail driver name must be provided")
}
logger.Info("Connecting mail client...", zap.String("driver", config.Driver))
if config.Driver == "dummy" {
return mi.NewDummy(logger)
}
if config.Driver == "sendgrid" {
var sgconfig mi.SendGridConfig
if err := mapstructure.Decode(config.Settings, &sgconfig); err != nil {
logger.Error("Failed to decode driver settings", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
return mi.NewSendGridNotifier(logger, producer, &sgconfig)
}
if config.Driver == "client" {
var gsmconfing mi.GSMConfig
if err := mapstructure.Decode(config.Settings, &gsmconfing); err != nil {
logger.Error("Failed to decode driver settings", zap.Error(err), zap.String("driver", config.Driver))
return nil, err
}
return mi.NewClient(logger, l, dp, &gsmconfing), nil
}
return nil, merrors.InvalidArgument("Unkwnown mail driver: " + config.Driver)
}
func CreateMailClient(logger mlogger.Logger, sender string, producer messaging.Producer, l localizer.Localizer, dp domainprovider.DomainProvider, config *Config) (Client, error) {
return NewAmpliMailer(logger, sender, producer, l, dp, config)
}

View File

@@ -0,0 +1,11 @@
package mmail
type MailBuilder interface {
SetAccountID(accountID string) MailBuilder
SetTemplateID(templateID string) MailBuilder
SetLocale(locale string) MailBuilder
AddRecipient(recipientName, recipient string) MailBuilder
AddButton(link string) MailBuilder
AddData(key, value string) MailBuilder
Build() (Message, error)
}

View File

@@ -0,0 +1,20 @@
package mmail
import (
"time"
mgt "github.com/tech/sendico/pkg/mutil/time/go"
)
func AddDate(b MailBuilder, t time.Time) {
b.AddData("Date", mgt.ToDate(t))
}
func AddTime(b MailBuilder, t time.Time) {
b.AddData("Time", mgt.ToTime(t))
}
func AddDateAndTime(b MailBuilder, t time.Time) {
AddDate(b, t)
AddTime(b, t)
}

View File

@@ -0,0 +1,15 @@
package mmail
import (
"github.com/tech/sendico/notification/interface/api/localizer"
"github.com/tech/sendico/pkg/domainprovider"
)
type Message interface {
AccountID() string
TemplateID() string
Locale() string
Recipients() []string
Parameters() map[string]any
Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error)
}

View File

@@ -0,0 +1,68 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/notification/interface/api"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail"
"github.com/tech/sendico/pkg/domainprovider"
na "github.com/tech/sendico/pkg/messaging/notifications/account"
ni "github.com/tech/sendico/pkg/messaging/notifications/invitation"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
type NotificationAPI struct {
logger mlogger.Logger
client mmail.Client
dp domainprovider.DomainProvider
}
func (a *NotificationAPI) Name() mservice.Type {
return mservice.Notifications
}
func (a *NotificationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a api.API) (*NotificationAPI, error) {
p := &NotificationAPI{
dp: a.DomainProvider(),
}
p.logger = a.Logger().Named(p.Name())
var err error
if p.client, err = mmail.CreateMailClient(p.logger.Named("mailer"), p.Name(), a.Register().Producer(), a.Localizer(), a.DomainProvider(), a.Config().Notification); err != nil {
p.logger.Error("Failed to create mail connection", zap.Error(err), zap.String("driver", a.Config().Notification.Driver))
return nil, err
}
db, err := a.DBFactory().NewAccountDB()
if err != nil {
p.logger.Error("Failed to create account db connection", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(na.NewAccountCreatedMessageProcessor(p.logger, db, p.onAccount)); err != nil {
p.logger.Error("Failed to create account creation handler", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(na.NewPasswordResetRequestedMessageProcessor(p.logger, db, p.onPasswordReset)); err != nil {
p.logger.Error("Failed to create password reset handler", zap.Error(err))
return nil, err
}
idb, err := a.DBFactory().NewInvitationsDB()
if err != nil {
p.logger.Error("Failed to create invitation db connection", zap.Error(err))
return nil, err
}
if err := a.Register().Consumer(ni.NewInvitationCreatedProcessor(p.logger, p.onInvitation, idb, db)); err != nil {
p.logger.Error("Failed to create invitation creation handler", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,541 @@
package notificationimp
import (
"context"
"errors"
"testing"
"github.com/tech/sendico/notification/interface/api/localizer"
mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail/messagebuilder"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mlogger/factory"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// Mock implementations
type mockMailClient struct {
sendFunc func(r mmail.MailBuilder) error
mailBuilderFunc func() mmail.MailBuilder
sentMessages []mockSentMessage
}
type mockSentMessage struct {
accountID string
templateID string
locale string
recipients []string
data map[string]string
buttonLink string
}
func (m *mockMailClient) Send(r mmail.MailBuilder) error {
if m.sendFunc != nil {
return m.sendFunc(r)
}
// Record the message for verification
msg, _ := r.Build()
if msg != nil {
sent := mockSentMessage{
accountID: msg.AccountID(),
templateID: msg.TemplateID(),
locale: msg.Locale(),
recipients: msg.Recipients(),
data: make(map[string]string),
}
// Extract string parameters
for k, v := range msg.Parameters() {
if str, ok := v.(string); ok {
sent.data[k] = str
}
}
m.sentMessages = append(m.sentMessages, sent)
}
return nil
}
func (m *mockMailClient) MailBuilder() mmail.MailBuilder {
if m.mailBuilderFunc != nil {
return m.mailBuilderFunc()
}
return &mockMailBuilder{
accountID: "",
templateID: "",
locale: "",
recipients: []string{},
data: make(map[string]string),
}
}
type mockMailBuilder struct {
accountID string
templateID string
locale string
recipients []string
buttonLink string
data map[string]string
}
func (m *mockMailBuilder) SetAccountID(accountID string) mmail.MailBuilder {
m.accountID = accountID
return m
}
func (m *mockMailBuilder) SetTemplateID(templateID string) mmail.MailBuilder {
m.templateID = templateID
return m
}
func (m *mockMailBuilder) SetLocale(locale string) mmail.MailBuilder {
m.locale = locale
return m
}
func (m *mockMailBuilder) AddRecipient(recipientName, recipient string) mmail.MailBuilder {
m.recipients = append(m.recipients, recipient)
return m
}
func (m *mockMailBuilder) AddButton(link string) mmail.MailBuilder {
m.buttonLink = link
return m
}
func (m *mockMailBuilder) AddData(key, value string) mmail.MailBuilder {
m.data[key] = value
return m
}
func (m *mockMailBuilder) Build() (mmail.Message, error) {
if len(m.recipients) == 0 {
return nil, errors.New("recipient not set")
}
return &mockMessage{
accountID: m.accountID,
templateID: m.templateID,
locale: m.locale,
recipients: m.recipients,
parameters: convertToAnyMap(m.data),
}, nil
}
type mockMessage struct {
accountID string
templateID string
locale string
recipients []string
parameters map[string]any
}
func (m *mockMessage) AccountID() string { return m.accountID }
func (m *mockMessage) TemplateID() string { return m.templateID }
func (m *mockMessage) Locale() string { return m.locale }
func (m *mockMessage) Recipients() []string { return m.recipients }
func (m *mockMessage) Parameters() map[string]any { return m.parameters }
func (m *mockMessage) Body(l localizer.Localizer, dp domainprovider.DomainProvider) (string, error) {
return "", nil
}
func convertToAnyMap(m map[string]string) map[string]any {
result := make(map[string]any)
for k, v := range m {
result[k] = v
}
return result
}
type mockDomainProvider struct {
getFullLinkFunc func(linkElem ...string) (string, error)
}
func (m *mockDomainProvider) GetFullLink(linkElem ...string) (string, error) {
if m.getFullLinkFunc != nil {
return m.getFullLinkFunc(linkElem...)
}
return "https://example.com/link", nil
}
func (m *mockDomainProvider) GetAPILink(linkElem ...string) (string, error) {
return "https://api.example.com/link", nil
}
// Tests for onAccount handler
func TestOnAccount_ValidAccount_SendsWelcomeEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "welcome" {
t.Errorf("Expected template 'welcome', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "user@example.com" {
t.Errorf("Expected recipient 'user@example.com', got %v", sent.recipients)
}
}
func TestOnAccount_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
func TestOnAccount_SendFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{
sendFunc: func(r mmail.MailBuilder) error {
return errors.New("send failed")
},
}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
VerifyToken: "test-verify-token",
}
err := api.onAccount(context.Background(), account)
if err == nil {
t.Fatal("Expected error from send failure")
}
}
// Tests for onInvitation handler
func TestOnInvitation_ValidInvitation_SendsInvitationEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Inviter User",
},
},
UserDataBase: model.UserDataBase{
Locale: "en-US",
},
},
}
invitationID := primitive.NewObjectID()
invitation := &model.Invitation{}
invitation.ID = invitationID
invitation.Content.Email = "invitee@example.com"
invitation.Content.Name = "Invitee Name"
err := api.onInvitation(context.Background(), account, invitation)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "invitation" {
t.Errorf("Expected template 'invitation', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "invitee@example.com" {
t.Errorf("Expected recipient 'invitee@example.com', got %v", sent.recipients)
}
if sent.data["InviterName"] != "Inviter User" {
t.Errorf("Expected InviterName 'Inviter User', got '%s'", sent.data["InviterName"])
}
if sent.data["Name"] != "Invitee Name" {
t.Errorf("Expected Name 'Invitee Name', got '%s'", sent.data["Name"])
}
}
func TestOnInvitation_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Inviter User",
},
},
UserDataBase: model.UserDataBase{
Locale: "en-US",
},
},
}
invitationID := primitive.NewObjectID()
invitation := &model.Invitation{}
invitation.ID = invitationID
invitation.Content.Email = "invitee@example.com"
invitation.Content.Name = "Invitee Name"
err := api.onInvitation(context.Background(), account, invitation)
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
// Tests for onPasswordReset handler
func TestOnPasswordReset_ValidReset_SendsResetEmail(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
resetToken := "reset-token-123"
err := api.onPasswordReset(context.Background(), account, resetToken)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(mockClient.sentMessages) != 1 {
t.Fatalf("Expected 1 message sent, got %d", len(mockClient.sentMessages))
}
sent := mockClient.sentMessages[0]
if sent.templateID != "reset-password" {
t.Errorf("Expected template 'reset-password', got '%s'", sent.templateID)
}
if sent.locale != "en-US" {
t.Errorf("Expected locale 'en-US', got '%s'", sent.locale)
}
if len(sent.recipients) != 1 || sent.recipients[0] != "user@example.com" {
t.Errorf("Expected recipient 'user@example.com', got %v", sent.recipients)
}
if sent.data["URL"] == "" {
t.Error("Expected URL parameter to be set")
}
}
func TestOnPasswordReset_LinkGenerationFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{}
mockDP := &mockDomainProvider{
getFullLinkFunc: func(linkElem ...string) (string, error) {
return "", errors.New("link generation failed")
},
}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
err := api.onPasswordReset(context.Background(), account, "reset-token")
if err == nil {
t.Fatal("Expected error from link generation failure")
}
if len(mockClient.sentMessages) != 0 {
t.Error("No message should be sent when link generation fails")
}
}
func TestOnPasswordReset_SendFails_ReturnsError(t *testing.T) {
mockClient := &mockMailClient{
sendFunc: func(r mmail.MailBuilder) error {
return errors.New("send failed")
},
}
mockDP := &mockDomainProvider{}
api := &NotificationAPI{
logger: mlogger.NewLogger(true),
client: mockClient,
dp: mockDP,
}
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Base: storable.Base{
ID: primitive.NewObjectID(),
},
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "user@example.com",
Locale: "en-US",
},
},
}
err := api.onPasswordReset(context.Background(), account, "reset-token")
if err == nil {
t.Fatal("Expected error from send failure")
}
}

View File

@@ -0,0 +1,30 @@
package notificationimp
import (
"context"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *NotificationAPI) onPasswordReset(context context.Context, account *model.Account, resetToken string) error {
var link string
var err error
if link, err = a.dp.GetFullLink("password", "reset", account.ID.Hex(), resetToken); err != nil {
a.logger.Warn("Failed to generate password reset link", zap.Error(err), zap.String("login", account.Login))
return err
}
mr := a.client.MailBuilder().
AddRecipient(account.Name, account.Login).
SetAccountID(account.ID.Hex()).
SetLocale(account.Locale).
AddButton(link).
AddData("URL", link).
SetTemplateID("reset-password")
if err := a.client.Send(mr); err != nil {
a.logger.Warn("Failed to send password reset email", zap.Error(err), zap.String("login", account.Login))
return err
}
a.logger.Info("Password reset email sent", zap.String("login", account.Login))
return nil
}

View File

@@ -0,0 +1,11 @@
package server
import (
serverimp "github.com/tech/sendico/notification/internal/server/internal"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
)
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return serverimp.Create(logger, file, debug)
}

33
api/notification/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"github.com/tech/sendico/notification/internal/appversion"
si "github.com/tech/sendico/notification/internal/server"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/server"
smain "github.com/tech/sendico/pkg/server/main"
)
// generate translations
// go:generate Users/stephandeshevikh/go/bin/go18n extract
// go:generate Users/stephandeshevikh/go/bin/go18n merge
// lint go code
// docker run -t --rm -v $(pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run -v --timeout 10m0s --enable-all -D ireturn -D wrapcheck -D varnamelen -D tagliatelle -D nosnakecase -D gochecknoglobals -D nlreturn -D stylecheck -D lll -D wsl -D scopelint -D varcheck -D exhaustivestruct -D golint -D maligned -D interfacer -D ifshort -D structcheck -D deadcode -D godot -D depguard -D tagalign
// gofumpt source files
//go:generate /Users/stephandeshevikh/go/bin/gofumpt -w .
// gci source files
//go:generate /Users/stephandeshevikh/go/bin/gci write .
// get new ampli events
//go:generate ampli pull backend --path ./internal/ampli
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
return si.Create(logger, file, debug)
}
func main() {
smain.RunServer("notification", appversion.Create(), factory)
}

BIN
api/notification/notification Executable file

Binary file not shown.

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)
}

View File

@@ -1,8 +0,0 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type CreatePriorityGroup struct {
Description model.Describable `json:"description"`
Priorities []model.Colorable `json:"priorities"`
}

View File

@@ -1,31 +0,0 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type CreateProject struct {
Project model.Describable `json:"project"`
LogoURI *string `json:"logoUrl,omitempty"`
PrioriyGroupRef primitive.ObjectID `json:"priorityGroupRef"`
StatusGroupRef primitive.ObjectID `json:"statusGroupRef"`
Mnemonic string `json:"mnemonic"`
}
type ProjectPreview struct {
Projects []primitive.ObjectID `json:"projects"`
}
type TagFilterMode string
const (
TagFilterModeNone TagFilterMode = "none"
TagFilterModePresent TagFilterMode = "present"
TagFilterModeMissing TagFilterMode = "missing"
TagFilterModeIncludeAny TagFilterMode = "includeAny"
TagFilterModeIncludeAll TagFilterMode = "includeAll"
TagFilterModeExcludeAny TagFilterMode = "excludeAny"
)
type ProjectsFilter = model.ProjectFilterBase

View File

@@ -1,11 +0,0 @@
package srequest
import (
"go.mongodb.org/mongo-driver/bson/primitive"
)
// DeleteProject represents a request to delete a project
type DeleteProject struct {
OrganizationRef primitive.ObjectID `json:"organizationRef"` // If provided, move tasks to this project. If null, delete all tasks
MoveTasksToProjectRef *primitive.ObjectID `json:"moveTasksToProjectRef,omitempty"` // If provided, move tasks to this project. If null, delete all tasks
}

View File

@@ -3,12 +3,10 @@ package srequest
import "github.com/tech/sendico/pkg/model"
type Signup struct {
Account model.AccountData `json:"account"`
OrganizationName string `json:"organizationName"`
OrganizationTimeZone string `json:"organizationTimeZone"`
DefaultPriorityGroup CreatePriorityGroup `json:"defaultPriorityGroup"`
DefaultStatusGroup CreateStatusGroup `json:"defaultStatusGroup"`
AnonymousUser model.Describable `json:"anonymousUser"`
OwnerRole model.Describable `json:"ownerRole"`
AnonymousRole model.Describable `json:"anonymousRole"`
Account model.AccountData `json:"account"`
OrganizationName string `json:"organizationName"`
OrganizationTimeZone string `json:"organizationTimeZone"`
AnonymousUser model.Describable `json:"anonymousUser"`
OwnerRole model.Describable `json:"ownerRole"`
AnonymousRole model.Describable `json:"anonymousRole"`
}

View File

@@ -5,17 +5,12 @@ import (
"fmt"
"testing"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
func TestSignupRequest_JSONSerialization(t *testing.T) {
signup := srequest.Signup{
Account: model.AccountData{
@@ -29,25 +24,6 @@ func TestSignupRequest_JSONSerialization(t *testing.T) {
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
@@ -75,19 +51,9 @@ func TestSignupRequest_JSONSerialization(t *testing.T) {
assert.Equal(t, signup.Account.Password, unmarshaled.Account.Password)
assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName)
assert.Equal(t, signup.OrganizationTimeZone, unmarshaled.OrganizationTimeZone)
assert.Equal(t, signup.DefaultPriorityGroup.Description.Name, unmarshaled.DefaultPriorityGroup.Description.Name)
assert.Equal(t, len(signup.DefaultPriorityGroup.Priorities), len(unmarshaled.DefaultPriorityGroup.Priorities))
assert.Equal(t, signup.AnonymousUser.Name, unmarshaled.AnonymousUser.Name)
assert.Equal(t, signup.OwnerRole.Name, unmarshaled.OwnerRole.Name)
assert.Equal(t, signup.AnonymousRole.Name, unmarshaled.AnonymousRole.Name)
// Verify priorities
for i, priority := range signup.DefaultPriorityGroup.Priorities {
assert.Equal(t, priority.Name, unmarshaled.DefaultPriorityGroup.Priorities[i].Name)
if priority.Color != nil && unmarshaled.DefaultPriorityGroup.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *unmarshaled.DefaultPriorityGroup.Priorities[i].Color)
}
}
}
func TestSignupRequest_MinimalValidRequest(t *testing.T) {
@@ -103,17 +69,6 @@ func TestSignupRequest_MinimalValidRequest(t *testing.T) {
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Normal"},
Color: stringPtr("#000000"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous",
},
@@ -139,7 +94,6 @@ func TestSignupRequest_MinimalValidRequest(t *testing.T) {
assert.Equal(t, signup.Account.Name, unmarshaled.Account.Name)
assert.Equal(t, signup.Account.Login, unmarshaled.Account.Login)
assert.Equal(t, signup.OrganizationName, unmarshaled.OrganizationName)
assert.Len(t, unmarshaled.DefaultPriorityGroup.Priorities, 1)
}
func TestSignupRequest_InvalidJSON(t *testing.T) {
@@ -175,17 +129,6 @@ func TestSignupRequest_UnicodeCharacters(t *testing.T) {
},
OrganizationName: "测试 Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "默认 Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "高"},
Color: stringPtr("#FF0000"),
},
},
},
AnonymousUser: model.Describable{
Name: "匿名 User",
},
@@ -211,102 +154,7 @@ func TestSignupRequest_UnicodeCharacters(t *testing.T) {
assert.Equal(t, "测试@example.com", unmarshaled.Account.Login)
assert.Equal(t, "Test 用户 Üser", unmarshaled.Account.Name)
assert.Equal(t, "测试 Organization", unmarshaled.OrganizationName)
assert.Equal(t, "默认 Priority Group", unmarshaled.DefaultPriorityGroup.Description.Name)
assert.Equal(t, "高", unmarshaled.DefaultPriorityGroup.Priorities[0].Name)
assert.Equal(t, "匿名 User", unmarshaled.AnonymousUser.Name)
assert.Equal(t, "所有者", unmarshaled.OwnerRole.Name)
assert.Equal(t, "匿名", unmarshaled.AnonymousRole.Name)
}
func TestCreatePriorityGroup_JSONSerialization(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Test Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Critical"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF8000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify all fields are properly serialized/deserialized
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Equal(t, len(priorityGroup.Priorities), len(unmarshaled.Priorities))
for i, priority := range priorityGroup.Priorities {
assert.Equal(t, priority.Name, unmarshaled.Priorities[i].Name)
if priority.Color != nil && unmarshaled.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *unmarshaled.Priorities[i].Color)
}
}
}
func TestCreatePriorityGroup_EmptyPriorities(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Empty Priority Group",
},
Priorities: []model.Colorable{},
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify empty priorities array is handled correctly
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Empty(t, unmarshaled.Priorities)
}
func TestCreatePriorityGroup_NilPriorities(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Nil Priority Group",
},
Priorities: nil,
}
// Test JSON marshaling
jsonData, err := json.Marshal(priorityGroup)
require.NoError(t, err)
assert.NotEmpty(t, jsonData)
// Test JSON unmarshaling
var unmarshaled srequest.CreatePriorityGroup
err = json.Unmarshal(jsonData, &unmarshaled)
require.NoError(t, err)
// Verify nil priorities is handled correctly
assert.Equal(t, priorityGroup.Description.Name, unmarshaled.Description.Name)
assert.Nil(t, unmarshaled.Priorities)
}

View File

@@ -1,16 +0,0 @@
package srequest
import (
"github.com/tech/sendico/pkg/model"
)
type CreateStatus struct {
model.Colorable `json:"description"`
Icon string `json:"icon"`
IsFinal bool `json:"isFinal"`
}
type CreateStatusGroup struct {
Description model.Describable `json:"description"`
Statuses []CreateStatus `json:"statuses"`
}

View File

@@ -1,24 +0,0 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type commentPreviewResponse struct {
authResponse `json:",inline"`
Comments []model.CommentPreview `json:"comments"`
}
func CommentPreview(logger mlogger.Logger, accessToken *TokenData, comments []model.CommentPreview) http.HandlerFunc {
return response.Ok(
logger,
&commentPreviewResponse{
Comments: comments,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}

View File

@@ -1,37 +0,0 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type projectsResponse struct {
authResponse `json:",inline"`
Projects []model.Project `json:"projects"`
}
func Projects(logger mlogger.Logger, projects []model.Project, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, projectsResponse{
Projects: projects,
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Project(logger mlogger.Logger, project *model.Project, accessToken *TokenData) http.HandlerFunc {
return Projects(logger, []model.Project{*project}, accessToken)
}
type projectPreviewsResponse struct {
authResponse `json:",inline"`
Previews []model.ProjectPreview `json:"previews"`
}
func ProjectsPreviews(logger mlogger.Logger, previews []model.ProjectPreview, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, &projectPreviewsResponse{
authResponse: authResponse{AccessToken: *accessToken},
Previews: previews,
})
}

View File

@@ -1,25 +0,0 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
)
type statusesResponse struct {
authResponse `json:",inline"`
Statuses []model.Status `json:"statuses"`
}
func Statuses(logger mlogger.Logger, statuses []model.Status, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, statusesResponse{
Statuses: statuses,
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Status(logger mlogger.Logger, status *model.Status, accessToken *TokenData) http.HandlerFunc {
return Statuses(logger, []model.Status{*status}, accessToken)
}

View File

@@ -69,25 +69,6 @@ func TestSignupRequestSerialization(t *testing.T) {
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
@@ -114,15 +95,7 @@ func TestSignupRequestSerialization(t *testing.T) {
assert.Equal(t, signupRequest.Account.Name, retrieved.Account.Name)
assert.Equal(t, signupRequest.OrganizationName, retrieved.OrganizationName)
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
assert.Equal(t, len(signupRequest.DefaultPriorityGroup.Priorities), len(retrieved.DefaultPriorityGroup.Priorities))
// Verify priorities
for i, priority := range signupRequest.DefaultPriorityGroup.Priorities {
assert.Equal(t, priority.Name, retrieved.DefaultPriorityGroup.Priorities[i].Name)
if priority.Color != nil && retrieved.DefaultPriorityGroup.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *retrieved.DefaultPriorityGroup.Priorities[i].Color)
}
}
})
}
@@ -140,17 +113,6 @@ func TestSignupHTTPSerialization(t *testing.T) {
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},

View File

@@ -4,9 +4,9 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/stretchr/testify/assert"
)
// Helper function to create string pointers
@@ -64,25 +64,6 @@ func TestCreateValidSignupRequest(t *testing.T) {
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
@@ -100,10 +81,6 @@ func TestCreateValidSignupRequest(t *testing.T) {
assert.Equal(t, "Test User", request.Account.Name)
assert.Equal(t, "Test Organization", request.OrganizationName)
assert.Equal(t, "UTC", request.OrganizationTimeZone)
assert.Equal(t, "Default Priority Group", request.DefaultPriorityGroup.Description.Name)
assert.Len(t, request.DefaultPriorityGroup.Priorities, 3)
assert.Equal(t, "High", request.DefaultPriorityGroup.Priorities[0].Name)
assert.Equal(t, "#FF0000", *request.DefaultPriorityGroup.Priorities[0].Color)
}
// TestSignupRequestValidation tests various signup request validation scenarios
@@ -219,52 +196,6 @@ func TestSignupRequestValidation(t *testing.T) {
})
}
// TestPriorityGroupCreation tests the priority group structure
func TestPriorityGroupCreation(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Test Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Critical"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF8000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
}
assert.Equal(t, "Test Priority Group", priorityGroup.Description.Name)
assert.Len(t, priorityGroup.Priorities, 4)
// Test each priority
expectedPriorities := []struct {
name string
color string
}{
{"Critical", "#FF0000"},
{"High", "#FF8000"},
{"Medium", "#FFFF00"},
{"Low", "#00FF00"},
}
for i, expected := range expectedPriorities {
assert.Equal(t, expected.name, priorityGroup.Priorities[i].Name)
assert.Equal(t, expected.color, *priorityGroup.Priorities[i].Color)
}
}
// TestAccountDataToAccount tests the ToAccount method
func TestAccountDataToAccount(t *testing.T) {
accountData := model.AccountData{