fx build fix
This commit is contained in:
BIN
api/.DS_Store
vendored
Normal file
BIN
api/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
api/notification/.DS_Store
vendored
Normal file
BIN
api/notification/.DS_Store
vendored
Normal file
Binary file not shown.
57
api/notification/.air.toml
Normal file
57
api/notification/.air.toml
Normal 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
1
api/notification/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
go.sum
|
||||
14
api/notification/ampli.json
Normal file
14
api/notification/ampli.json
Normal 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
85
api/notification/config.yml
Executable 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
1
api/notification/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
58
api/notification/go.mod
Normal file
58
api/notification/go.mod
Normal 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
|
||||
)
|
||||
59
api/notification/i18n/en.json
Normal file
59
api/notification/i18n/en.json
Normal file
File diff suppressed because one or more lines are too long
61
api/notification/i18n/ru.json
Normal file
61
api/notification/i18n/ru.json
Normal file
File diff suppressed because one or more lines are too long
59
api/notification/i18n/uk.json
Normal file
59
api/notification/i18n/uk.json
Normal file
File diff suppressed because one or more lines are too long
21
api/notification/interface/api/api.go
Normal file
21
api/notification/interface/api/api.go
Normal 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)
|
||||
13
api/notification/interface/api/config.go
Normal file
13
api/notification/interface/api/config.go
Normal 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"`
|
||||
}
|
||||
19
api/notification/interface/api/localizer/localizer.go
Normal file
19
api/notification/interface/api/localizer/localizer.go
Normal 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)
|
||||
}
|
||||
12
api/notification/interface/middleware/middleware.go
Normal file
12
api/notification/interface/middleware/middleware.go
Normal 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
|
||||
11
api/notification/interface/services/amplitude/amplitude.go
Normal file
11
api/notification/interface/services/amplitude/amplitude.go
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package amplitude
|
||||
|
||||
type Config struct {
|
||||
Environment string `yaml:"ampli_environment_env"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package notificationimp
|
||||
|
||||
type Config struct {
|
||||
Driver string `yaml:"driver"`
|
||||
Settings map[string]any `yaml:"settings,omitempty"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
734
api/notification/internal/ampli/ampli.go
Normal file
734
api/notification/internal/ampli/ampli.go
Normal 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 = &litude.Plan{
|
||||
Branch: `main`,
|
||||
Source: `backend`,
|
||||
Version: `2`,
|
||||
VersionID: `4fa6851a-4ff0-42f1-b440-8b39f07870e4`,
|
||||
}
|
||||
}
|
||||
|
||||
if clientConfig.IngestionMetadata == nil {
|
||||
clientConfig.IngestionMetadata = &litude.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...)
|
||||
}
|
||||
146
api/notification/internal/api/api.go
Normal file
146
api/notification/internal/api/api.go
Normal 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
|
||||
}
|
||||
42
api/notification/internal/api/config/config.go
Executable file
42
api/notification/internal/api/config/config.go
Executable 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
|
||||
59
api/notification/internal/api/middleware.go
Normal file
59
api/notification/internal/api/middleware.go
Normal 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
|
||||
}
|
||||
27
api/notification/internal/appversion/version.go
Executable file
27
api/notification/internal/appversion/version.go
Executable 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)
|
||||
}
|
||||
151
api/notification/internal/localizer/loc_imp.go
Normal file
151
api/notification/internal/localizer/loc_imp.go
Normal 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
|
||||
}
|
||||
45
api/notification/internal/server/amplitude/amplitude.go
Executable file
45
api/notification/internal/server/amplitude/amplitude.go
Executable 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
|
||||
}
|
||||
16
api/notification/internal/server/amplitude/nsent.go
Normal file
16
api/notification/internal/server/amplitude/nsent.go
Normal 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
|
||||
}
|
||||
128
api/notification/internal/server/internal/serverimp.go
Normal file
128
api/notification/internal/server/internal/serverimp.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
174
api/notification/internal/server/notificationimp/mail/internal/mailimp.go
Executable file
174
api/notification/internal/server/notificationimp/mail/internal/mailimp.go
Executable 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
11
api/notification/internal/server/server.go
Normal file
11
api/notification/internal/server/server.go
Normal 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
33
api/notification/main.go
Normal 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
BIN
api/notification/notification
Executable file
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user