diff --git a/api/.DS_Store b/api/.DS_Store new file mode 100644 index 0000000..eace314 Binary files /dev/null and b/api/.DS_Store differ diff --git a/api/notification/.DS_Store b/api/notification/.DS_Store new file mode 100644 index 0000000..c82e4d9 Binary files /dev/null and b/api/notification/.DS_Store differ diff --git a/api/notification/.air.toml b/api/notification/.air.toml new file mode 100644 index 0000000..8b52776 --- /dev/null +++ b/api/notification/.air.toml @@ -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 \ No newline at end of file diff --git a/api/notification/.gitignore b/api/notification/.gitignore new file mode 100644 index 0000000..c14a297 --- /dev/null +++ b/api/notification/.gitignore @@ -0,0 +1 @@ +go.sum \ No newline at end of file diff --git a/api/notification/ampli.json b/api/notification/ampli.json new file mode 100644 index 0000000..2635e30 --- /dev/null +++ b/api/notification/ampli.json @@ -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" +} \ No newline at end of file diff --git a/api/notification/config.yml b/api/notification/config.yml new file mode 100755 index 0000000..884d5ec --- /dev/null +++ b/api/notification/config.yml @@ -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 \ No newline at end of file diff --git a/api/notification/env/.gitignore b/api/notification/env/.gitignore new file mode 100644 index 0000000..d71ab6c --- /dev/null +++ b/api/notification/env/.gitignore @@ -0,0 +1 @@ +.env.api \ No newline at end of file diff --git a/api/notification/go.mod b/api/notification/go.mod new file mode 100644 index 0000000..02e9a91 --- /dev/null +++ b/api/notification/go.mod @@ -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 +) diff --git a/api/notification/i18n/en.json b/api/notification/i18n/en.json new file mode 100644 index 0000000..0cc34ad --- /dev/null +++ b/api/notification/i18n/en.json @@ -0,0 +1,59 @@ +{ + "service.owner": "Dudina Yuliia Oleksandrivna SP #3307203248", + "service.address": "Ukraine, 62100, Kharkiv region, Bogoduhiv district, Bogoduhiv city, Svyato-Dukhivska square, b. 5, a. 1", + "service.phone": "phone: +380 (93) 106-29-50", + + "calendar.summary": "Meeting with {{.BookerName}}", + "calendar.description": "Hi<\/h1>\r\n

\r\nMeeting with {{.BookerName}} is confirmed on {{.Date}} at {{.Time}}.\r\n\r\nNotes:\r\n{{.Note}}\r\n\r\nShould you feel like rescheduling your meeting, follow this link<\/a>.\r\nIf you want to cancel your meeting, click here<\/a>. \r\n\r\nYours faithfully,\r\n{{.ServiceName}} team\r\n <\/p>\r\n <\/div>\r\n \r\n <\/center>\r\n <\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/body>\r\n<\/html>", + + "mail.welcome.subj": "Welcome to {{.ServiceName}}", + "mail.welcome.greeting": "Welcome, {{.Name}}", + "mail.welcome.body": "You're receiving this message because you recently signed up for a {{.ServiceName}} account.

Confirm your email address by clicking the button below. This step adds extra security by verifying you own this email.", + "btn.welcome": "Confirm email", + + "mail.invitation.subj": "{{.InviterName}} invited you to join {{.ServiceName}}", + "mail.invitation.greeting": "Hello, {{.Name}}", + "mail.invitation.body": "{{.InviterName}} has invited you to join {{.ServiceName}}.

Click the button below to accept the invitation and create your account.", + "btn.invitation": "Accept invitation", + + "mail.reset-password.subj": "{{.ServiceName}}: reset password request", + "mail.reset-password.greeting": "Hi, {{.Name}}", + "mail.reset-password.body": "It looks like you requested a new password.

If that sounds right, you can enter a new password by clicking the following
link<\/a>.

If you have not requested a passowrd change please contact us under
{{.SupportMail}}<\/a>.", + + "mail.email-verification.subj": "{{.ServiceName}}: verify your email address", + "mail.email-verification.greeting": "Hi, {{.Name}}", + "mail.email-verification.body": "It looks like you have changed your email address.

If that sounds right, verify your email address by clicking the following
link<\/a>.

If you have not changed your email address please contact us under
{{.SupportMail}}<\/a>.", + + "mail.email-successful-verification.subj": "{{.ServiceName}}: your email address is now verified", + "mail.email-successful-verification.greeting": "Congrats, {{.Name}}", + "mail.email-successful-verification.body": "Your e-mail has been successfully verified. Now it's time to log in and add content to your profile.", + + "mail.email-changed.subj": "{{.ServiceName}}: your email address has been changed", + "mail.email-changed.greeting": "Hi, {{.Name}}", + "mail.email-changed.body": "You changed your email address to {{.NewEmail}}.

If you have not changed your email address please contact us under
{{.SupportMail}}<\/a>", + + "mail.booking-confirmation.subj": "{{.ServiceName}}: your meeting confirmation", + "mail.booking-confirmation.greeting": "Hi, {{.Name}}", + "mail.booking-confirmation.body": "Your meeting time slot is confirmed on {{.Date}} at {{.Time}}.

Should you feel like rescheduling your meeting, follow this
link<\/a>.

If you want to cancel your meeting, click
here<\/a>.", + + "mail.booking-cancellation.subj": "{{.ServiceName}}: your meeting was cancelled", + "mail.booking-cancellation.greeting": "Hi, {{.Name}}", + "mail.booking-cancellation.body": "Your meeting on {{.Date}} at {{.Time}} was cancelled.", + + "mail.booking-reschedule.subj": "{{.ServiceName}}: your meeting was rescheduled", + "mail.booking-reschedule.greeting": "Hi, {{.Name}}", + "mail.booking-reschedule.body": "Your meeting was rescheduled for a new time on {{.Date}} at {{.Time}}.

Should you feel like rescheduling your meeting again, follow this
link<\/a>.

If you want to cancel your meeting, click
here<\/a>.", + + "mail.template.one_button": "\r\n\r\n \r\n \r\n \r\n \r\n \r\n \r\n {{.MessageTitle}}<\/title><!--[if (mso 16)]>\r\n <style type=\"text\/css\">\r\n a {text-decoration: none;}\r\n <\/style>\r\n <![endif]--><!--[if gte mso 9]><style>sup { font-size: 100% !important; }<\/style><![endif]--><!--[if gte mso 9]>\r\n<xml>\r\n <o:OfficeDocumentSettings>\r\n <o:AllowPNG><\/o:AllowPNG>\r\n <o:PixelsPerInch>96<\/o:PixelsPerInch>\r\n <\/o:OfficeDocumentSettings>\r\n<\/xml>\r\n<![endif]--><!--[if !mso]><!-- -->\r\n <link href=\"https:\/\/fonts.googleapis.com\/css2?family=Imprima&display=swap\" rel=\"stylesheet\"><!--<![endif]--><!--[if !mso]><!-- -->\r\n <link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css?family=Montserrat:100,300,400,500,700,900\"><!--<![endif]-->\r\n <style type=\"text\/css\">\r\n#outlook a {\r\n\tpadding:0;\r\n}\r\n.es-button {\r\n\tmso-style-priority:100!important;\r\n\ttext-decoration:none!important;\r\n}\r\na[x-apple-data-detectors] {\r\n\tcolor:inherit!important;\r\n\ttext-decoration:none!important;\r\n\tfont-size:inherit!important;\r\n\tfont-family:inherit!important;\r\n\tfont-weight:inherit!important;\r\n\tline-height:inherit!important;\r\n}\r\n.es-desk-hidden {\r\n\tdisplay:none;\r\n\tfloat:left;\r\n\toverflow:hidden;\r\n\twidth:0;\r\n\tmax-height:0;\r\n\tline-height:0;\r\n\tmso-hide:all;\r\n}\r\n@media only screen and (max-width:600px) {p, ul li, ol li, a { line-height:150%!important } h1, h2, h3, h1 a, h2 a, h3 a { line-height:120% } h1 { font-size:30px!important; text-align:left } h2 { font-size:24px!important; text-align:left } h3 { font-size:20px!important; text-align:left } .es-header-body h1 a, .es-content-body h1 a, .es-footer-body h1 a { font-size:30px!important; text-align:left } .es-header-body h2 a, .es-content-body h2 a, .es-footer-body h2 a { font-size:24px!important; text-align:left } .es-header-body h3 a, .es-content-body h3 a, .es-footer-body h3 a { font-size:20px!important; text-align:left } .es-menu td a { font-size:14px!important } .es-header-body p, .es-header-body ul li, .es-header-body ol li, .es-header-body a { font-size:14px!important } .es-content-body p, .es-content-body ul li, .es-content-body ol li, .es-content-body a { font-size:14px!important } .es-footer-body p, .es-footer-body ul li, .es-footer-body ol li, .es-footer-body a { font-size:14px!important } .es-infoblock p, .es-infoblock ul li, .es-infoblock ol li, .es-infoblock a { font-size:12px!important } *[class=\"gmail-fix\"] { display:none!important } .es-m-txt-c, .es-m-txt-c h1, .es-m-txt-c h2, .es-m-txt-c h3 { text-align:center!important } .es-m-txt-r, .es-m-txt-r h1, .es-m-txt-r h2, .es-m-txt-r h3 { text-align:right!important } .es-m-txt-l, .es-m-txt-l h1, .es-m-txt-l h2, .es-m-txt-l h3 { text-align:left!important } .es-m-txt-r img, .es-m-txt-c img, .es-m-txt-l img { display:inline!important } .es-button-border { display:block!important } a.es-button, button.es-button { font-size:18px!important; display:block!important; border-right-width:0px!important; border-left-width:0px!important; border-top-width:15px!important; border-bottom-width:15px!important; padding-left:0px!important; padding-right:0px!important } .es-adaptive table, .es-left, .es-right { width:100%!important } .es-content table, .es-header table, .es-footer table, .es-content, .es-footer, .es-header { width:100%!important; max-width:600px!important } .es-adapt-td { display:block!important; width:100%!important } .adapt-img { width:100%!important; height:auto!important } .es-m-p0 { padding:0px!important } .es-m-p0r { padding-right:0px!important } .es-m-p0l { padding-left:0px!important } .es-m-p0t { padding-top:0px!important } .es-m-p0b { padding-bottom:0!important } .es-m-p20b { padding-bottom:20px!important } .es-mobile-hidden, .es-hidden { display:none!important } tr.es-desk-hidden, td.es-desk-hidden, table.es-desk-hidden { width:auto!important; overflow:visible!important; float:none!important; max-height:inherit!important; line-height:inherit!important } tr.es-desk-hidden { display:table-row!important } table.es-desk-hidden { display:table!important } td.es-desk-menu-hidden { display:table-cell!important } .es-menu td { width:1%!important } table.es-table-not-adapt, .esd-block-html table { width:auto!important } table.es-social { display:inline-block!important } table.es-social td { display:inline-block!important } .es-desk-hidden { display:table-row!important; width:auto!important; overflow:visible!important; max-height:inherit!important } }\r\n<\/style>\r\n <\/head>\r\n <body style=\"width:100%;font-family:montserrat, roboto;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;padding:0;Margin:0\">\r\n <div class=\"es-wrapper-color\" style=\"background-color:#FFFFFF\"><!--[if gte mso 9]>\r\n\t\t\t<v:background xmlns:v=\"urn:schemas-microsoft-com:vml\" fill=\"t\">\r\n\t\t\t\t<v:fill type=\"tile\" color=\"#ffffff\"><\/v:fill>\r\n\t\t\t<\/v:background>\r\n\t\t<![endif]-->\r\n <table class=\"es-wrapper\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;padding:0;Margin:0;width:100%;height:100%;background-repeat:repeat;background-position:center top;background-color:#FFFFFF\">\r\n <tr>\r\n <td valign=\"top\" style=\"padding:0;Margin:0\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#ffffff\" class=\"es-content-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;border-radius:20px 20px 0 0;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;padding-top:20px;padding-left:40px;padding-right:40px;border-radius:8px 8px 0px 0px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"center\" valign=\"top\" style=\"padding:0;Margin:0;width:520px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" bgcolor=\"#fafafa\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:separate;border-spacing:0px;background-color:#fafafa;border-radius:10px\" role=\"presentation\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:20px;Margin:0\">\r\n <h3 style=\"Margin:0;line-height:34px;mso-line-height-rule:exactly;font-family:montserrat, roboto;font-size:28px;font-style:normal;font-weight:bold;color:#2D3142\">\r\n {{.Greeting}}\r\n <\/h3>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\"><br><\/p>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\">\r\n {{.Content}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n {{.ButtonBlock}}\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-footer\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%;background-color:transparent;background-repeat:repeat;background-position:center top\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#bcb8b1\" class=\"es-footer-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"Margin:0;padding-left:20px;padding-right:20px;padding-bottom:30px;padding-top:40px\"><!--[if mso]><table style=\"width:560px\" cellpadding=\"0\" cellspacing=\"0\"><tr><td style=\"width:82px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" align=\"left\" class=\"es-left\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:left\">\r\n <tr>\r\n <td align=\"left\" class=\"es-m-p20b\" style=\"padding:0;Margin:0;width:82px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td \r\n align=\"center\" \r\n style=\"padding:0;Margin:0;padding-left:20px;font-size:0px\"><img class=\"adapt-img\" \r\n src=\"{{.LogoLink}}\" \r\n alt \r\n style=\"display:block;border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic\" width=\"62\"\r\n \/>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><td style=\"width:20px\"><\/td><td style=\"width:458px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-right\" align=\"right\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:right\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;width:458px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" href=\"\"><\/a>\r\n <a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.PolicyLink}}\"\r\n >\r\n {{.Privacy}}\r\n <\/a>\r\n {{.UnsubscribeBlock}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.ServiceOwner}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerAddress}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerPhone}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><\/tr><\/table><![endif]--><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/div>\r\n <\/body>\r\n<\/html>", + + "mail.template.privacy": "Privacy policy", + + "mail.template.unsubscribe.block": "<a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:13px\" href=\"\">\r\n <\/a>\r\n \u2022 \r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.UnsubscribeLink}}\"\r\n >\r\n {{.Unsubscribe}}\r\n <\/a>", + "mail.template.unsubscribe": "Unsubscribe", + + "mail.template.btn.block": "<tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\"><!--[if mso]><a href=\"\" target=\"_blank\" hidden>\r\n <v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w=\"urn:schemas-microsoft-com:office:word\" esdevVmlButton href=\"{{.ButtonLink}}\" \r\n style=\"height:56px; v-text-anchor:middle; width:520px\" arcsize=\"14%\" stroke=\"f\" fillcolor=\"#0b58ff\">\r\n <w:anchorlock><\/w:anchorlock>\r\n <center style='color:#ffffff; font-family:montserrat, roboto; font-size:22px; font-weight:700; line-height:22px; mso-text-raise:1px'>Confirm email<\/center>\r\n <\/v:roundrect><\/a>\r\n <![endif]--><!--[if !mso]><!-- -->\r\n <span class=\"msohide es-button-border\" style=\"border-style:solid;border-color:#0b58ff;background:#0b58ff;border-width:0px;display:block;border-radius:8px;width:auto;mso-border-alt:10px;mso-hide:all;width:520px\">\r\n <a \r\n href=\"{{.ButtonLink}}\" \r\n class=\"es-button msohide\" \r\n target=\"_blank\" \r\n style=\"mso-style-priority:100 !important;text-decoration:none;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;color:#ffffff;font-size:22px;padding:15px 20px 15px 20px;display:block;background:#0b58ff;border-radius:8px;font-family:montserrat, roboto;font-weight:bold;font-style:normal;line-height:26px;width:auto;text-align:center;border-color:#0b58ff;mso-hide:all;padding-left:5px;padding-right:5px\"\r\n >\r\n {{.ButtonText}}\r\n <\/a>\r\n <\/span><!--<![endif]-->\r\n <\/td>\r\n <\/tr>", + "btn.verify": "Verify", + "btn.reset-password": "Reset password", + + "___file_trailer": "" +} diff --git a/api/notification/i18n/ru.json b/api/notification/i18n/ru.json new file mode 100644 index 0000000..b7f28a6 --- /dev/null +++ b/api/notification/i18n/ru.json @@ -0,0 +1,61 @@ +{ + "service.owner": "ФОП Дудiна Юлiя Олександрiвна (ЄДРПОУ 3307203248)", + "service.address": "Україна, 62100, Харківська обл., Богодухівський р-н, місто Богодухів, пл.Свято-Духівська, 5-1", + "service.phone": "тел.: +380 (93) 106-29-50", + + "calendar.summary": "Встреча с {{.BookerName}}", + "calendar.description": "<!DOCTYPE html PUBLIC \"-\/\/W3C\/\/DTD XHTML 1.0 Strict\/\/EN\" \"http:\/\/www.w3.org\/TR\/xhtml1\/DTD\/xhtml1-strict.dtd\">\r\n<html xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\r\n <head>\r\n <meta http-equiv=\"Content-Type\" content=\"text\/html; charset=utf-8\" \/>\r\n <meta name=\"viewport\" content=\"width=device-width\" \/>\r\n <!-- NOTE: external links are for testing only -->\r\n <link href=\"\/\/cdn.muicss.com\/mui-0.9.41\/email\/mui-email-styletag.css\" rel=\"stylesheet\" \/>\r\n <link href=\"\/\/cdn.muicss.com\/mui-0.9.41\/email\/mui-email-inline.css\" rel=\"stylesheet\" \/>\r\n <\/head>\r\n <body>\r\n <table class=\"mui-body\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\r\n <tr>\r\n <td>\r\n <center>\r\n <!--[if mso]><table><tr><td class=\"mui-container-fixed\"><![endif]-->\r\n <div class=\"mui-container\">\r\n <p>\r\n\u0412\u0430\u0448\u0430 \u0432\u0441\u0442\u0440\u0435\u0447\u0430 c {{.BookerName}} \u00A0\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0430 \u043D\u0430 {{.Date}} \u0432 {{.Time}}.\r\n<br\/>\r\n\u041F\u0440\u0438\u043C\u0435\u0447\u0430\u043D\u0438\u044F:\r\n<br\/>{{.Note}}\r\n<br\/><br\/>\u0415\u0441\u043B\u0438 \u0443 \u0432\u0430\u0441 \u0447\u0442\u043E-\u0442\u043E \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u043E\u0441\u044C, \u0438 \u0432\u0430\u043C \u043D\u0430\u0434\u043E \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0442\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0443, \u0442\u043E \u043D\u0430\u0436\u043C\u0438\u0442\u0435 <a href=\"{{.URLReschedule}}\">\u0441\u044E\u0434\u0430<\/a>.\r\n<br\/><br\/>\u0427\u0442\u043E\u0431\u044B \u043E\u0442\u043C\u0435\u043D\u0438\u0442\u044C \u0432\u0441\u0442\u0440\u0435\u0447\u0443, \u043D\u0430\u0436\u043C\u0438\u0442\u0435 <a href=\"{{.URLCancel}}\">\u0442\u0443\u0442<\/a>. \r\n<br\/><br\/>\r\n<br\/><br\/>\u0418\u0441\u043A\u0440\u0435\u043D\u043D\u0435 \u0432\u0430\u0448\u0430,\r\n<br\/><br\/>\u041A\u043E\u043C\u0430\u043D\u0434\u0430 {{.ServiceName}}\r\n <\/p>\r\n <\/div>\r\n <!--[if mso]><\/td><\/tr><\/table><![endif]-->\r\n <\/center>\r\n <\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/body>\r\n<\/html>", + + "mail.welcome.subj": "{{.ServiceName}}: добро пожаловать!", + "mail.welcome.greeting": "Добро пожаловать, {{.Name}}", + "mail.welcome.body": "Вы получили это сообщение, поскольку недавно зарегистрировали учетную запись {{.ServiceName}}.<br><br>Подтвердите свой адрес электронной почты, нажав кнопку ниже. Этот шаг обеспечивает дополнительную безопасность, подтверждая, что вы являетесь владельцем этого адреса электронной почты.", + "btn.welcome": "Подтвердить email", + + "mail.invitation.subj": "{{.InviterName}} приглашает вас присоединиться к {{.ServiceName}}", + "mail.invitation.greeting": "Здравствуйте, {{.Name}}", + "mail.invitation.body": "{{.InviterName}} пригласил вас присоединиться к {{.ServiceName}}.<br><br>Нажмите на кнопку ниже, чтобы принять приглашение и создать аккаунт.", + "btn.invitation": "Принять приглашение", + + + "mail.reset-password.subj": "{{.ServiceName}}: запрошен сброс пароля", + "mail.reset-password.greeting": "День добрый, {{.Name}}", + "mail.reset-password.body": "От вашего имени пришел запрос на сброс пароля.<br><br>Если этот запрос отправили вы, то подтвердите сброс переходом по <a href=\"{{.URL}}\">ссылке<\/a>.<br><br>Если этот запрос отправили не вы, то дайте нам знать по адресу электронной почты <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}<\/a>", + + "mail.email-verification.subj": "{{.ServiceName}}: подтвердите ваш адрес электронной почты", + "mail.email-verification.greeting": "День добрый, {{.Name}}", + "mail.email-verification.body": "От вашего имени пришел запрос на смену адреса электронной почты.<br><br>Если этот запрос отправили вы, то подтвердите смену переходом по <a href=\"{{.URL}}\">ссылке <\/a>.<br><br>Если этот запрос отправили не вы, то дайте нам знать по адресу электронной почты <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}<\/a>", + + "mail.email-successful-verification.subj": "{{.ServiceName}}: ваш адрес подтвержден", + "mail.email-successful-verification.greeting": "{{.Name}}, поздравляем!", + "mail.email-successful-verification.body": "Ваш адрес электронной почты подтвержден, учетная запись активирована. Теперь пришло время войти в систему и наполнить контентом ваш профиль.", + + "mail.email-changed.subj": "{{.ServiceName}}: ваш адрес изменен", + "mail.email-changed.greeting": "День добрый, {{.Name}}", + "mail.email-changed.body": "Вы изменили свой адрес электронной почты на {{.NewEmail}}.<br><br>Если вы не запрашвали и не подтверждали смену электронной почты, свяжитесь с нами по адресу <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}</a>", + + "mail.booking-confirmation.subj": "{{.ServiceName}}: ваша встреча подтверждена", + "mail.booking-confirmation.greeting": "День добрый, {{.Name}}", + "mail.booking-confirmation.body": "Время вашей встречи подтверждено на {{.Date}} в {{.Time}}.<br><br>Если вы хотите перенести встречу, перейдите по этой <a href=\"{{.URLReschedule}}\">ссылке<\/a>.<br><br>Если вы хотите отменить встречу, нажмите <a href=\"{{.URLCancel}}\">здесь<\/a>.", + + "mail.booking-cancellation.subj": "{{.ServiceName}}: ваша встреча отменена", + "mail.booking-cancellation.greeting": "День добрый, {{.Name}}", + "mail.booking-cancellation.body": "Ваша встреча {{.Date}} в {{.Time}} была отменена.", + + "mail.booking-reschedule.subj": "{{.ServiceName}}: ваша встреча назначена на новое время", + "mail.booking-reschedule.greeting": "День добрый, {{.Name}}", + "mail.booking-reschedule.body": "Ваша встреча была перенесена на новое время {{.Date}} в {{.Time}}.<br><br>Если вы хотите перенести встречу еще раз, перейдите по этой <a href=\"{{.URLReschedule} }\">ссылке<\/a>.<br><br>Если вы хотите отменить встречу, нажмите <a href=\"{{.URLCancel}}\">тут<\/a>.", + + "mail.template.one_button": "<!DOCTYPE html PUBLIC \"-\/\/W3C\/\/DTD XHTML 1.0 Transitional\/\/EN\" \"http:\/\/www.w3.org\/TR\/xhtml1\/DTD\/xhtml1-transitional.dtd\">\r\n<html xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" style=\"font-family:montserrat, roboto\">\r\n <head>\r\n <meta charset=\"UTF-8\">\r\n <meta content=\"width=device-width, initial-scale=1\" name=\"viewport\">\r\n <meta name=\"x-apple-disable-message-reformatting\">\r\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\r\n <meta content=\"telephone=no\" name=\"format-detection\">\r\n <title>{{.MessageTitle}}<\/title><!--[if (mso 16)]>\r\n <style type=\"text\/css\">\r\n a {text-decoration: none;}\r\n <\/style>\r\n <![endif]--><!--[if gte mso 9]><style>sup { font-size: 100% !important; }<\/style><![endif]--><!--[if gte mso 9]>\r\n<xml>\r\n <o:OfficeDocumentSettings>\r\n <o:AllowPNG><\/o:AllowPNG>\r\n <o:PixelsPerInch>96<\/o:PixelsPerInch>\r\n <\/o:OfficeDocumentSettings>\r\n<\/xml>\r\n<![endif]--><!--[if !mso]><!-- -->\r\n <link href=\"https:\/\/fonts.googleapis.com\/css2?family=Imprima&display=swap\" rel=\"stylesheet\"><!--<![endif]--><!--[if !mso]><!-- -->\r\n <link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css?family=Montserrat:100,300,400,500,700,900\"><!--<![endif]-->\r\n <style type=\"text\/css\">\r\n#outlook a {\r\n\tpadding:0;\r\n}\r\n.es-button {\r\n\tmso-style-priority:100!important;\r\n\ttext-decoration:none!important;\r\n}\r\na[x-apple-data-detectors] {\r\n\tcolor:inherit!important;\r\n\ttext-decoration:none!important;\r\n\tfont-size:inherit!important;\r\n\tfont-family:inherit!important;\r\n\tfont-weight:inherit!important;\r\n\tline-height:inherit!important;\r\n}\r\n.es-desk-hidden {\r\n\tdisplay:none;\r\n\tfloat:left;\r\n\toverflow:hidden;\r\n\twidth:0;\r\n\tmax-height:0;\r\n\tline-height:0;\r\n\tmso-hide:all;\r\n}\r\n@media only screen and (max-width:600px) {p, ul li, ol li, a { line-height:150%!important } h1, h2, h3, h1 a, h2 a, h3 a { line-height:120% } h1 { font-size:30px!important; text-align:left } h2 { font-size:24px!important; text-align:left } h3 { font-size:20px!important; text-align:left } .es-header-body h1 a, .es-content-body h1 a, .es-footer-body h1 a { font-size:30px!important; text-align:left } .es-header-body h2 a, .es-content-body h2 a, .es-footer-body h2 a { font-size:24px!important; text-align:left } .es-header-body h3 a, .es-content-body h3 a, .es-footer-body h3 a { font-size:20px!important; text-align:left } .es-menu td a { font-size:14px!important } .es-header-body p, .es-header-body ul li, .es-header-body ol li, .es-header-body a { font-size:14px!important } .es-content-body p, .es-content-body ul li, .es-content-body ol li, .es-content-body a { font-size:14px!important } .es-footer-body p, .es-footer-body ul li, .es-footer-body ol li, .es-footer-body a { font-size:14px!important } .es-infoblock p, .es-infoblock ul li, .es-infoblock ol li, .es-infoblock a { font-size:12px!important } *[class=\"gmail-fix\"] { display:none!important } .es-m-txt-c, .es-m-txt-c h1, .es-m-txt-c h2, .es-m-txt-c h3 { text-align:center!important } .es-m-txt-r, .es-m-txt-r h1, .es-m-txt-r h2, .es-m-txt-r h3 { text-align:right!important } .es-m-txt-l, .es-m-txt-l h1, .es-m-txt-l h2, .es-m-txt-l h3 { text-align:left!important } .es-m-txt-r img, .es-m-txt-c img, .es-m-txt-l img { display:inline!important } .es-button-border { display:block!important } a.es-button, button.es-button { font-size:18px!important; display:block!important; border-right-width:0px!important; border-left-width:0px!important; border-top-width:15px!important; border-bottom-width:15px!important; padding-left:0px!important; padding-right:0px!important } .es-adaptive table, .es-left, .es-right { width:100%!important } .es-content table, .es-header table, .es-footer table, .es-content, .es-footer, .es-header { width:100%!important; max-width:600px!important } .es-adapt-td { display:block!important; width:100%!important } .adapt-img { width:100%!important; height:auto!important } .es-m-p0 { padding:0px!important } .es-m-p0r { padding-right:0px!important } .es-m-p0l { padding-left:0px!important } .es-m-p0t { padding-top:0px!important } .es-m-p0b { padding-bottom:0!important } .es-m-p20b { padding-bottom:20px!important } .es-mobile-hidden, .es-hidden { display:none!important } tr.es-desk-hidden, td.es-desk-hidden, table.es-desk-hidden { width:auto!important; overflow:visible!important; float:none!important; max-height:inherit!important; line-height:inherit!important } tr.es-desk-hidden { display:table-row!important } table.es-desk-hidden { display:table!important } td.es-desk-menu-hidden { display:table-cell!important } .es-menu td { width:1%!important } table.es-table-not-adapt, .esd-block-html table { width:auto!important } table.es-social { display:inline-block!important } table.es-social td { display:inline-block!important } .es-desk-hidden { display:table-row!important; width:auto!important; overflow:visible!important; max-height:inherit!important } }\r\n<\/style>\r\n <\/head>\r\n <body style=\"width:100%;font-family:montserrat, roboto;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;padding:0;Margin:0\">\r\n <div class=\"es-wrapper-color\" style=\"background-color:#FFFFFF\"><!--[if gte mso 9]>\r\n\t\t\t<v:background xmlns:v=\"urn:schemas-microsoft-com:vml\" fill=\"t\">\r\n\t\t\t\t<v:fill type=\"tile\" color=\"#ffffff\"><\/v:fill>\r\n\t\t\t<\/v:background>\r\n\t\t<![endif]-->\r\n <table class=\"es-wrapper\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;padding:0;Margin:0;width:100%;height:100%;background-repeat:repeat;background-position:center top;background-color:#FFFFFF\">\r\n <tr>\r\n <td valign=\"top\" style=\"padding:0;Margin:0\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#ffffff\" class=\"es-content-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;border-radius:20px 20px 0 0;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;padding-top:20px;padding-left:40px;padding-right:40px;border-radius:8px 8px 0px 0px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"center\" valign=\"top\" style=\"padding:0;Margin:0;width:520px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" bgcolor=\"#fafafa\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:separate;border-spacing:0px;background-color:#fafafa;border-radius:10px\" role=\"presentation\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:20px;Margin:0\">\r\n <h3 style=\"Margin:0;line-height:34px;mso-line-height-rule:exactly;font-family:montserrat, roboto;font-size:28px;font-style:normal;font-weight:bold;color:#2D3142\">\r\n {{.Greeting}}\r\n <\/h3>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\"><br><\/p>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\">\r\n {{.Content}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n {{.ButtonBlock}}\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-footer\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%;background-color:transparent;background-repeat:repeat;background-position:center top\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#bcb8b1\" class=\"es-footer-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"Margin:0;padding-left:20px;padding-right:20px;padding-bottom:30px;padding-top:40px\"><!--[if mso]><table style=\"width:560px\" cellpadding=\"0\" cellspacing=\"0\"><tr><td style=\"width:82px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" align=\"left\" class=\"es-left\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:left\">\r\n <tr>\r\n <td align=\"left\" class=\"es-m-p20b\" style=\"padding:0;Margin:0;width:82px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td \r\n align=\"center\" \r\n style=\"padding:0;Margin:0;padding-left:20px;font-size:0px\"><img class=\"adapt-img\" \r\n src=\"{{.LogoLink}}\" \r\n alt \r\n style=\"display:block;border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic\" width=\"62\"\r\n \/>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><td style=\"width:20px\"><\/td><td style=\"width:458px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-right\" align=\"right\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:right\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;width:458px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" href=\"\"><\/a>\r\n <a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.PolicyLink}}\"\r\n >\r\n {{.Privacy}}\r\n <\/a>\r\n {{.UnsubscribeBlock}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.ServiceOwner}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerAddress}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerPhone}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><\/tr><\/table><![endif]--><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/div>\r\n <\/body>\r\n<\/html>", + + "mail.template.privacy": "Политика конфиденциальности", + + "mail.template.unsubscribe.block": "<a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:13px\" href=\"\">\r\n <\/a>\r\n \u2022 \r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.UnsubscribeLink}}\"\r\n >\r\n {{.Unsubscribe}}\r\n <\/a>", + "mail.template.unsubscribe": "Отписаться", + + "mail.template.btn.block": "<tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\"><!--[if mso]><a href=\"\" target=\"_blank\" hidden>\r\n <v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w=\"urn:schemas-microsoft-com:office:word\" esdevVmlButton href=\"{{.ButtonLink}}\" \r\n style=\"height:56px; v-text-anchor:middle; width:520px\" arcsize=\"14%\" stroke=\"f\" fillcolor=\"#0b58ff\">\r\n <w:anchorlock><\/w:anchorlock>\r\n <center style='color:#ffffff; font-family:montserrat, roboto; font-size:22px; font-weight:700; line-height:22px; mso-text-raise:1px'>Confirm email<\/center>\r\n <\/v:roundrect><\/a>\r\n <![endif]--><!--[if !mso]><!-- -->\r\n <span class=\"msohide es-button-border\" style=\"border-style:solid;border-color:#0b58ff;background:#0b58ff;border-width:0px;display:block;border-radius:8px;width:auto;mso-border-alt:10px;mso-hide:all;width:520px\">\r\n <a \r\n href=\"{{.ButtonLink}}\" \r\n class=\"es-button msohide\" \r\n target=\"_blank\" \r\n style=\"mso-style-priority:100 !important;text-decoration:none;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;color:#ffffff;font-size:22px;padding:15px 20px 15px 20px;display:block;background:#0b58ff;border-radius:8px;font-family:montserrat, roboto;font-weight:bold;font-style:normal;line-height:26px;width:auto;text-align:center;border-color:#0b58ff;mso-hide:all;padding-left:5px;padding-right:5px\"\r\n >\r\n {{.ButtonText}}\r\n <\/a>\r\n <\/span><!--<![endif]-->\r\n <\/td>\r\n <\/tr>", + "btn.verify": "Подтвердить", + "btn.reset-password": "Сбросить пароль", + + + "___file_trailer": "" +} \ No newline at end of file diff --git a/api/notification/i18n/uk.json b/api/notification/i18n/uk.json new file mode 100644 index 0000000..8771068 --- /dev/null +++ b/api/notification/i18n/uk.json @@ -0,0 +1,59 @@ +{ + "service.owner": "ФОП Дудiна Юлiя Олександрiвна (ЄДРПОУ 3307203248)", + "service.address": "Україна, 62100, Харківська обл., Богодухівський р-н, місто Богодухів, пл.Свято-Духівська, 5-1", + "service.phone": "тел.: +380 (93) 106-29-50", + + "calendar.summary": "Зустріч із {{.BookerName}}", + "calendar.description": "<!DOCTYPE html PUBLIC \"-\/\/W3C\/\/DTD XHTML 1.0 Strict\/\/EN\" \"http:\/\/www.w3.org\/TR\/xhtml1\/DTD\/xhtml1-strict.dtd\">\r\n<html xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\r\n <head>\r\n <meta http-equiv=\"Content-Type\" content=\"text\/html; charset=utf-8\" \/>\r\n <meta name=\"viewport\" content=\"width=device-width\" \/>\r\n <!-- NOTE: external links are for testing only -->\r\n <link href=\"\/\/cdn.muicss.com\/mui-0.9.41\/email\/mui-email-styletag.css\" rel=\"stylesheet\" \/>\r\n <link href=\"\/\/cdn.muicss.com\/mui-0.9.41\/email\/mui-email-inline.css\" rel=\"stylesheet\" \/>\r\n <\/head>\r\n <body>\r\n <table class=\"mui-body\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\">\r\n <tr>\r\n <td>\r\n <center>\r\n <!--[if mso]><table><tr><td class=\"mui-container-fixed\"><![endif]-->\r\n <div class=\"mui-container\">\r\n <p>\r\n\u0412\u0430\u0448\u0430 \u0432\u0441\u0442\u0440\u0435\u0447\u0430 c {{.BookerName}} \u00A0\u043F\u043E\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043D\u0430 \u043D\u0430 {{.Date}} \u0432 {{.Time}}.\r\n<br\/>\r\n\u041F\u0440\u0438\u043C\u0435\u0447\u0430\u043D\u0438\u044F:\r\n<br\/>{{.Note}}\r\n<br\/><br\/>\u0415\u0441\u043B\u0438 \u0443 \u0432\u0430\u0441 \u0447\u0442\u043E-\u0442\u043E \u0438\u0437\u043C\u0435\u043D\u0438\u043B\u043E\u0441\u044C, \u0438 \u0432\u0430\u043C \u043D\u0430\u0434\u043E \u043F\u0435\u0440\u0435\u043D\u0435\u0441\u0442\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0443, \u0442\u043E \u043D\u0430\u0436\u043C\u0438\u0442\u0435 <a href=\"{{.URLReschedule}}\">\u0441\u044E\u0434\u0430<\/a>.\r\n<br\/><br\/>\u0427\u0442\u043E\u0431\u044B \u043E\u0442\u043C\u0435\u043D\u0438\u0442\u044C \u0432\u0441\u0442\u0440\u0435\u0447\u0443, \u043D\u0430\u0436\u043C\u0438\u0442\u0435 <a href=\"{{.URLCancel}}\">\u0442\u0443\u0442<\/a>. \r\n<br\/><br\/>\r\n<br\/><br\/>\u0418\u0441\u043A\u0440\u0435\u043D\u043D\u0435 \u0432\u0430\u0448\u0430,\r\n<br\/><br\/>\u041A\u043E\u043C\u0430\u043D\u0434\u0430 {{.ServiceName}}\r\n <\/p>\r\n <\/div>\r\n <!--[if mso]><\/td><\/tr><\/table><![endif]-->\r\n <\/center>\r\n <\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/body>\r\n<\/html>", + + "mail.welcome.subj": "{{.ServiceName}}: вітаємо!", + "mail.welcome.greeting": "Вітаємо, {{.Name}}", + "mail.welcome.body": "Ви отримали це повідомлення, оскільки нещодавно зареєстрували обліковий запис {{.ServiceName}}.<br><br>Підтвердьте свою електронну адресу, натиснувши кнопку нижче. Цей крок додає додатковий захист, підтверджуючи, що ви володієте цією електронною адресою.", + "btn.welcome": "Підтвердити email", + + "mail.invitation.subj": "{{.InviterName}} запрошує вас приєднатися до {{.ServiceName}}", + "mail.invitation.greeting": "Вітаємо, {{.Name}}", + "mail.invitation.body": "{{.InviterName}} запросив вас приєднатися до {{.ServiceName}}.<br><br>Натисніть кнопку нижче, щоб прийняти запрошення та створити обліковий запис.", + "btn.invitation": "Прийняти запрошення", + + "mail.reset-password.subj": "{{.ServiceName}}: ви почали скидання пароля", + "mail.reset-password.greeting": "День добрий, {{.Name}}", + "mail.reset-password.body": "Від вашого імені прийшов запит на скидання пароля.<br><br>Якщо цей запит відправили ви, то підтвердіть скидання переходом по <a href=\"{{.URL}}\">посилання<\/a>.<br> ><br>Якщо цей запит надіслали не ви, дайте нам знати за адресою електронної пошти <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}<\/a>", + + "mail.email-verification.subj": "{{.ServiceName}}: Перевірте свою адресу електронної пошти", + "mail.email-verification.greeting": "День добрий, {{.Name}}", + "mail.email-verification.body": "Від вашого імені надійшов запит на адресу електронної пошти.<br><br>Якщо цей запит надіслали, то підтвердьте зміну переходом за <a href=\"{{.URL}}\">адресою<\/a>. <br><br>Якщо цей запит відправили не ви, то дайте нам знати на адресу електронної пошти <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}<\/a>", + + "mail.email-successful-verification.subj": "{{.ServiceName}}: ваша адреса підтверджена", + "mail.email-successful-verification.greeting": "Вітаємо, {{.Name}}", + "mail.email-successful-verification.body": "Вашу адресу електронної пошти підтверджено, обліковий запис активовано. Тепер настав час увійти в систему та наповнити контентом ваш профіль.", + + "mail.email-changed.subj": "{{.ServiceName}}: ваша адреса змінилася", + "mail.email-changed.greeting": "День добрий, {{.Name}}", + "mail.email-changed.body": "Ви змінили свою адресу електронної пошти на {{.NewEmail}}.<br><br>Якщо ви не запитували та не підтверджували зміну електронної пошти, зв'яжіться з нами за адресою <a href=\"mailto:{{.SupportMail}}\">{{.SupportMail}}</a>", + + "mail.booking-confirmation.subj": "{{.ServiceName}}: ваша зустріч підтверджена", + "mail.booking-confirmation.greeting": "День добрий, {{.Name}}", + "mail.booking-confirmation.body": "Час вашої зустрічі підтверджено на {{.Date}} в {{.Time}}.<br><br>Якщо ви хочете перенести зустріч, перейдіть по цій <a href=\"{{.URLReschedule}}\">посилання<\/a>.<br><br>Якщо ви бажаєте скасувати зустріч, натисніть <a href=\"{{.URLCancel}}\">тут<\/a>.", + + "mail.booking-cancellation.subj": "{{.ServiceName}}: ваша зустріч скасована", + "mail.booking-cancellation.greeting": "День добрий, {{.Name}}", + "mail.booking-cancellation.body": "Вашу зустріч {{.Date}} о {{.Time}} скасовано.", + + "mail.booking-reschedule.subj": "{{.ServiceName}}: ваша зустріч призначена на новий час", + "mail.booking-reschedule.body": "Ваша зустріч була перенесена на новий час {{.Date}} в {{.Time}}.<br><br>Якщо ви хочете перенести зустріч ще раз, перейдіть по цій <a href=\"{{.URLReschedule} } \">посилання<\/a>.<br><br>Якщо ви бажаєте скасувати зустріч, натисніть <a href=\"{{.URLCancel}}\">тут<\/a>.", + + "mail.template.one_button": "<!DOCTYPE html PUBLIC \"-\/\/W3C\/\/DTD XHTML 1.0 Transitional\/\/EN\" \"http:\/\/www.w3.org\/TR\/xhtml1\/DTD\/xhtml1-transitional.dtd\">\r\n<html xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" style=\"font-family:montserrat, roboto\">\r\n <head>\r\n <meta charset=\"UTF-8\">\r\n <meta content=\"width=device-width, initial-scale=1\" name=\"viewport\">\r\n <meta name=\"x-apple-disable-message-reformatting\">\r\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\r\n <meta content=\"telephone=no\" name=\"format-detection\">\r\n <title>{{.MessageTitle}}<\/title><!--[if (mso 16)]>\r\n <style type=\"text\/css\">\r\n a {text-decoration: none;}\r\n <\/style>\r\n <![endif]--><!--[if gte mso 9]><style>sup { font-size: 100% !important; }<\/style><![endif]--><!--[if gte mso 9]>\r\n<xml>\r\n <o:OfficeDocumentSettings>\r\n <o:AllowPNG><\/o:AllowPNG>\r\n <o:PixelsPerInch>96<\/o:PixelsPerInch>\r\n <\/o:OfficeDocumentSettings>\r\n<\/xml>\r\n<![endif]--><!--[if !mso]><!-- -->\r\n <link href=\"https:\/\/fonts.googleapis.com\/css2?family=Imprima&display=swap\" rel=\"stylesheet\"><!--<![endif]--><!--[if !mso]><!-- -->\r\n <link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css?family=Montserrat:100,300,400,500,700,900\"><!--<![endif]-->\r\n <style type=\"text\/css\">\r\n#outlook a {\r\n\tpadding:0;\r\n}\r\n.es-button {\r\n\tmso-style-priority:100!important;\r\n\ttext-decoration:none!important;\r\n}\r\na[x-apple-data-detectors] {\r\n\tcolor:inherit!important;\r\n\ttext-decoration:none!important;\r\n\tfont-size:inherit!important;\r\n\tfont-family:inherit!important;\r\n\tfont-weight:inherit!important;\r\n\tline-height:inherit!important;\r\n}\r\n.es-desk-hidden {\r\n\tdisplay:none;\r\n\tfloat:left;\r\n\toverflow:hidden;\r\n\twidth:0;\r\n\tmax-height:0;\r\n\tline-height:0;\r\n\tmso-hide:all;\r\n}\r\n@media only screen and (max-width:600px) {p, ul li, ol li, a { line-height:150%!important } h1, h2, h3, h1 a, h2 a, h3 a { line-height:120% } h1 { font-size:30px!important; text-align:left } h2 { font-size:24px!important; text-align:left } h3 { font-size:20px!important; text-align:left } .es-header-body h1 a, .es-content-body h1 a, .es-footer-body h1 a { font-size:30px!important; text-align:left } .es-header-body h2 a, .es-content-body h2 a, .es-footer-body h2 a { font-size:24px!important; text-align:left } .es-header-body h3 a, .es-content-body h3 a, .es-footer-body h3 a { font-size:20px!important; text-align:left } .es-menu td a { font-size:14px!important } .es-header-body p, .es-header-body ul li, .es-header-body ol li, .es-header-body a { font-size:14px!important } .es-content-body p, .es-content-body ul li, .es-content-body ol li, .es-content-body a { font-size:14px!important } .es-footer-body p, .es-footer-body ul li, .es-footer-body ol li, .es-footer-body a { font-size:14px!important } .es-infoblock p, .es-infoblock ul li, .es-infoblock ol li, .es-infoblock a { font-size:12px!important } *[class=\"gmail-fix\"] { display:none!important } .es-m-txt-c, .es-m-txt-c h1, .es-m-txt-c h2, .es-m-txt-c h3 { text-align:center!important } .es-m-txt-r, .es-m-txt-r h1, .es-m-txt-r h2, .es-m-txt-r h3 { text-align:right!important } .es-m-txt-l, .es-m-txt-l h1, .es-m-txt-l h2, .es-m-txt-l h3 { text-align:left!important } .es-m-txt-r img, .es-m-txt-c img, .es-m-txt-l img { display:inline!important } .es-button-border { display:block!important } a.es-button, button.es-button { font-size:18px!important; display:block!important; border-right-width:0px!important; border-left-width:0px!important; border-top-width:15px!important; border-bottom-width:15px!important; padding-left:0px!important; padding-right:0px!important } .es-adaptive table, .es-left, .es-right { width:100%!important } .es-content table, .es-header table, .es-footer table, .es-content, .es-footer, .es-header { width:100%!important; max-width:600px!important } .es-adapt-td { display:block!important; width:100%!important } .adapt-img { width:100%!important; height:auto!important } .es-m-p0 { padding:0px!important } .es-m-p0r { padding-right:0px!important } .es-m-p0l { padding-left:0px!important } .es-m-p0t { padding-top:0px!important } .es-m-p0b { padding-bottom:0!important } .es-m-p20b { padding-bottom:20px!important } .es-mobile-hidden, .es-hidden { display:none!important } tr.es-desk-hidden, td.es-desk-hidden, table.es-desk-hidden { width:auto!important; overflow:visible!important; float:none!important; max-height:inherit!important; line-height:inherit!important } tr.es-desk-hidden { display:table-row!important } table.es-desk-hidden { display:table!important } td.es-desk-menu-hidden { display:table-cell!important } .es-menu td { width:1%!important } table.es-table-not-adapt, .esd-block-html table { width:auto!important } table.es-social { display:inline-block!important } table.es-social td { display:inline-block!important } .es-desk-hidden { display:table-row!important; width:auto!important; overflow:visible!important; max-height:inherit!important } }\r\n<\/style>\r\n <\/head>\r\n <body style=\"width:100%;font-family:montserrat, roboto;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;padding:0;Margin:0\">\r\n <div class=\"es-wrapper-color\" style=\"background-color:#FFFFFF\"><!--[if gte mso 9]>\r\n\t\t\t<v:background xmlns:v=\"urn:schemas-microsoft-com:vml\" fill=\"t\">\r\n\t\t\t\t<v:fill type=\"tile\" color=\"#ffffff\"><\/v:fill>\r\n\t\t\t<\/v:background>\r\n\t\t<![endif]-->\r\n <table class=\"es-wrapper\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;padding:0;Margin:0;width:100%;height:100%;background-repeat:repeat;background-position:center top;background-color:#FFFFFF\">\r\n <tr>\r\n <td valign=\"top\" style=\"padding:0;Margin:0\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#ffffff\" class=\"es-content-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;border-radius:20px 20px 0 0;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;padding-top:20px;padding-left:40px;padding-right:40px;border-radius:8px 8px 0px 0px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"center\" valign=\"top\" style=\"padding:0;Margin:0;width:520px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" bgcolor=\"#fafafa\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:separate;border-spacing:0px;background-color:#fafafa;border-radius:10px\" role=\"presentation\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:20px;Margin:0\">\r\n <h3 style=\"Margin:0;line-height:34px;mso-line-height-rule:exactly;font-family:montserrat, roboto;font-size:28px;font-style:normal;font-weight:bold;color:#2D3142\">\r\n {{.Greeting}}\r\n <\/h3>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\"><br><\/p>\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:27px;color:#2D3142;font-size:18px\">\r\n {{.Content}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-content\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%\">\r\n {{.ButtonBlock}}\r\n <\/table>\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-footer\" align=\"center\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;table-layout:fixed !important;width:100%;background-color:transparent;background-repeat:repeat;background-position:center top\">\r\n <tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\">\r\n <table bgcolor=\"#bcb8b1\" class=\"es-footer-body\" align=\"center\" cellpadding=\"0\" cellspacing=\"0\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;background-color:#FFFFFF;width:600px\">\r\n <tr>\r\n <td align=\"left\" style=\"Margin:0;padding-left:20px;padding-right:20px;padding-bottom:30px;padding-top:40px\"><!--[if mso]><table style=\"width:560px\" cellpadding=\"0\" cellspacing=\"0\"><tr><td style=\"width:82px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" align=\"left\" class=\"es-left\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:left\">\r\n <tr>\r\n <td align=\"left\" class=\"es-m-p20b\" style=\"padding:0;Margin:0;width:82px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td \r\n align=\"center\" \r\n style=\"padding:0;Margin:0;padding-left:20px;font-size:0px\"><img class=\"adapt-img\" \r\n src=\"{{.LogoLink}}\" \r\n alt \r\n style=\"display:block;border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic\" width=\"62\"\r\n \/>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><td style=\"width:20px\"><\/td><td style=\"width:458px\" valign=\"top\"><![endif]-->\r\n <table cellpadding=\"0\" cellspacing=\"0\" class=\"es-right\" align=\"right\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px;float:right\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0;width:458px\">\r\n <table cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" role=\"presentation\" style=\"mso-table-lspace:0pt;mso-table-rspace:0pt;border-collapse:collapse;border-spacing:0px\">\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" href=\"\"><\/a>\r\n <a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.PolicyLink}}\"\r\n >\r\n {{.Privacy}}\r\n <\/a>\r\n {{.UnsubscribeBlock}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.ServiceOwner}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerAddress}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <tr>\r\n <td align=\"left\" style=\"padding:0;Margin:0\">\r\n <p style=\"Margin:0;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;font-family:montserrat, roboto;line-height:20px;color:#2D3142;font-size:13px\">\r\n {{.OwnerPhone}}\r\n <\/p>\r\n <\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><!--[if mso]><\/td><\/tr><\/table><![endif]--><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table><\/td>\r\n <\/tr>\r\n <\/table>\r\n <\/div>\r\n <\/body>\r\n<\/html>", + + "mail.template.privacy": "Політика конфіденційності", + + "mail.template.unsubscribe.block": "<a \r\n target=\"_blank\" \r\n style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:13px\" href=\"\">\r\n <\/a>\r\n \u2022 \r\n <a target=\"_blank\" style=\"-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;text-decoration:none;color:#2D3142;font-size:14px\" \r\n href=\"{{.UnsubscribeLink}}\"\r\n >\r\n {{.Unsubscribe}}\r\n <\/a>", + "mail.template.unsubscribe": "Відписатися", + + "mail.template.btn.block": "<tr>\r\n <td align=\"center\" style=\"padding:0;Margin:0\"><!--[if mso]><a href=\"\" target=\"_blank\" hidden>\r\n <v:roundrect xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w=\"urn:schemas-microsoft-com:office:word\" esdevVmlButton href=\"{{.ButtonLink}}\" \r\n style=\"height:56px; v-text-anchor:middle; width:520px\" arcsize=\"14%\" stroke=\"f\" fillcolor=\"#0b58ff\">\r\n <w:anchorlock><\/w:anchorlock>\r\n <center style='color:#ffffff; font-family:montserrat, roboto; font-size:22px; font-weight:700; line-height:22px; mso-text-raise:1px'>Confirm email<\/center>\r\n <\/v:roundrect><\/a>\r\n <![endif]--><!--[if !mso]><!-- -->\r\n <span class=\"msohide es-button-border\" style=\"border-style:solid;border-color:#0b58ff;background:#0b58ff;border-width:0px;display:block;border-radius:8px;width:auto;mso-border-alt:10px;mso-hide:all;width:520px\">\r\n <a \r\n href=\"{{.ButtonLink}}\" \r\n class=\"es-button msohide\" \r\n target=\"_blank\" \r\n style=\"mso-style-priority:100 !important;text-decoration:none;-webkit-text-size-adjust:none;-ms-text-size-adjust:none;mso-line-height-rule:exactly;color:#ffffff;font-size:22px;padding:15px 20px 15px 20px;display:block;background:#0b58ff;border-radius:8px;font-family:montserrat, roboto;font-weight:bold;font-style:normal;line-height:26px;width:auto;text-align:center;border-color:#0b58ff;mso-hide:all;padding-left:5px;padding-right:5px\"\r\n >\r\n {{.ButtonText}}\r\n <\/a>\r\n <\/span><!--<![endif]-->\r\n <\/td>\r\n <\/tr>", + "btn.verify": "Підтвердити", + "btn.reset-password": "Скинути пароль", + + + "___file_trailer": "" +} diff --git a/api/notification/interface/api/api.go b/api/notification/interface/api/api.go new file mode 100644 index 0000000..8410088 --- /dev/null +++ b/api/notification/interface/api/api.go @@ -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) diff --git a/api/notification/interface/api/config.go b/api/notification/interface/api/config.go new file mode 100644 index 0000000..3012be6 --- /dev/null +++ b/api/notification/interface/api/config.go @@ -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"` +} diff --git a/api/notification/interface/api/localizer/localizer.go b/api/notification/interface/api/localizer/localizer.go new file mode 100644 index 0000000..b825e72 --- /dev/null +++ b/api/notification/interface/api/localizer/localizer.go @@ -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) +} diff --git a/api/notification/interface/middleware/middleware.go b/api/notification/interface/middleware/middleware.go new file mode 100644 index 0000000..07fa4a3 --- /dev/null +++ b/api/notification/interface/middleware/middleware.go @@ -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 diff --git a/api/notification/interface/services/amplitude/amplitude.go b/api/notification/interface/services/amplitude/amplitude.go new file mode 100644 index 0000000..c669767 --- /dev/null +++ b/api/notification/interface/services/amplitude/amplitude.go @@ -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) +} diff --git a/api/notification/interface/services/amplitude/config/config.go b/api/notification/interface/services/amplitude/config/config.go new file mode 100644 index 0000000..8c46acb --- /dev/null +++ b/api/notification/interface/services/amplitude/config/config.go @@ -0,0 +1,5 @@ +package amplitude + +type Config struct { + Environment string `yaml:"ampli_environment_env"` +} diff --git a/api/notification/interface/services/notification/config/config.go b/api/notification/interface/services/notification/config/config.go new file mode 100644 index 0000000..2e34fd9 --- /dev/null +++ b/api/notification/interface/services/notification/config/config.go @@ -0,0 +1,6 @@ +package notificationimp + +type Config struct { + Driver string `yaml:"driver"` + Settings map[string]any `yaml:"settings,omitempty"` +} diff --git a/api/notification/interface/services/notification/notification.go b/api/notification/interface/services/notification/notification.go new file mode 100644 index 0000000..da58c2f --- /dev/null +++ b/api/notification/interface/services/notification/notification.go @@ -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) +} diff --git a/api/notification/internal/ampli/ampli.go b/api/notification/internal/ampli/ampli.go new file mode 100644 index 0000000..9bbed8a --- /dev/null +++ b/api/notification/internal/ampli/ampli.go @@ -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...) +} diff --git a/api/notification/internal/api/api.go b/api/notification/internal/api/api.go new file mode 100644 index 0000000..4beea9c --- /dev/null +++ b/api/notification/internal/api/api.go @@ -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 +} diff --git a/api/notification/internal/api/config/config.go b/api/notification/internal/api/config/config.go new file mode 100755 index 0000000..40df61c --- /dev/null +++ b/api/notification/internal/api/config/config.go @@ -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 diff --git a/api/notification/internal/api/middleware.go b/api/notification/internal/api/middleware.go new file mode 100644 index 0000000..4b59089 --- /dev/null +++ b/api/notification/internal/api/middleware.go @@ -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 +} diff --git a/api/notification/internal/appversion/version.go b/api/notification/internal/appversion/version.go new file mode 100755 index 0000000..fcfe94a --- /dev/null +++ b/api/notification/internal/appversion/version.go @@ -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) +} diff --git a/api/notification/internal/localizer/loc_imp.go b/api/notification/internal/localizer/loc_imp.go new file mode 100644 index 0000000..1e7d51c --- /dev/null +++ b/api/notification/internal/localizer/loc_imp.go @@ -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 +} diff --git a/api/notification/internal/server/amplitude/amplitude.go b/api/notification/internal/server/amplitude/amplitude.go new file mode 100755 index 0000000..9dad45a --- /dev/null +++ b/api/notification/internal/server/amplitude/amplitude.go @@ -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 +} diff --git a/api/notification/internal/server/amplitude/nsent.go b/api/notification/internal/server/amplitude/nsent.go new file mode 100644 index 0000000..495ba76 --- /dev/null +++ b/api/notification/internal/server/amplitude/nsent.go @@ -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 +} diff --git a/api/notification/internal/server/internal/serverimp.go b/api/notification/internal/server/internal/serverimp.go new file mode 100644 index 0000000..0bed5d1 --- /dev/null +++ b/api/notification/internal/server/internal/serverimp.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/accountcreated.go b/api/notification/internal/server/notificationimp/accountcreated.go new file mode 100644 index 0000000..a3b29d7 --- /dev/null +++ b/api/notification/internal/server/notificationimp/accountcreated.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/invitationcreated.go b/api/notification/internal/server/notificationimp/invitationcreated.go new file mode 100644 index 0000000..951778f --- /dev/null +++ b/api/notification/internal/server/notificationimp/invitationcreated.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/ampli.go b/api/notification/internal/server/notificationimp/mail/ampli.go new file mode 100644 index 0000000..93d7646 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/ampli.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/builder.go b/api/notification/internal/server/notificationimp/mail/internal/builder/builder.go new file mode 100644 index 0000000..a729130 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/builder.go @@ -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(), + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/builder_test.go b/api/notification/internal/server/notificationimp/mail/internal/builder/builder_test.go new file mode 100644 index 0000000..946b22f --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/builder_test.go @@ -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") + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/email.go b/api/notification/internal/server/notificationimp/mail/internal/builder/email.go new file mode 100644 index 0000000..beb7d1c --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/email.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/message.go b/api/notification/internal/server/notificationimp/mail/internal/builder/message.go new file mode 100644 index 0000000..04e0ac1 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/message.go @@ -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{}, + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/message_test.go b/api/notification/internal/server/notificationimp/mail/internal/builder/message_test.go new file mode 100644 index 0000000..8012638 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/message_test.go @@ -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) + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/builder/onebutton.go b/api/notification/internal/server/notificationimp/mail/internal/builder/onebutton.go new file mode 100644 index 0000000..717d3a5 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/builder/onebutton.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/dummy.go b/api/notification/internal/server/notificationimp/mail/internal/dummy.go new file mode 100644 index 0000000..2c7cb1d --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/dummy.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/dummy_test.go b/api/notification/internal/server/notificationimp/mail/internal/dummy_test.go new file mode 100644 index 0000000..ccf6b0d --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/dummy_test.go @@ -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") + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/mailimp.go b/api/notification/internal/server/notificationimp/mail/internal/mailimp.go new file mode 100755 index 0000000..87f2d32 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/mailimp.go @@ -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, + } +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/mailkey/mailkey.go b/api/notification/internal/server/notificationimp/mail/internal/mailkey/mailkey.go new file mode 100644 index 0000000..245fada --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/mailkey/mailkey.go @@ -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) +} diff --git a/api/notification/internal/server/notificationimp/mail/internal/sendgrid.go b/api/notification/internal/server/notificationimp/mail/internal/sendgrid.go new file mode 100644 index 0000000..8981cee --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/internal/sendgrid.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/mail/mail.go b/api/notification/internal/server/notificationimp/mail/mail.go new file mode 100644 index 0000000..6cacb60 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/mail.go @@ -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) +} diff --git a/api/notification/internal/server/notificationimp/mail/messagebuilder/builder.go b/api/notification/internal/server/notificationimp/mail/messagebuilder/builder.go new file mode 100644 index 0000000..0ca1c19 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/messagebuilder/builder.go @@ -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) +} diff --git a/api/notification/internal/server/notificationimp/mail/messagebuilder/datetime.go b/api/notification/internal/server/notificationimp/mail/messagebuilder/datetime.go new file mode 100644 index 0000000..71fb1f7 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/messagebuilder/datetime.go @@ -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) +} diff --git a/api/notification/internal/server/notificationimp/mail/messagebuilder/message.go b/api/notification/internal/server/notificationimp/mail/messagebuilder/message.go new file mode 100644 index 0000000..09dc227 --- /dev/null +++ b/api/notification/internal/server/notificationimp/mail/messagebuilder/message.go @@ -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) +} diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go new file mode 100644 index 0000000..e6d8f5b --- /dev/null +++ b/api/notification/internal/server/notificationimp/notification.go @@ -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 +} diff --git a/api/notification/internal/server/notificationimp/notification_test.go b/api/notification/internal/server/notificationimp/notification_test.go new file mode 100644 index 0000000..23a3811 --- /dev/null +++ b/api/notification/internal/server/notificationimp/notification_test.go @@ -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") + } +} diff --git a/api/notification/internal/server/notificationimp/password_reset.go b/api/notification/internal/server/notificationimp/password_reset.go new file mode 100644 index 0000000..4683693 --- /dev/null +++ b/api/notification/internal/server/notificationimp/password_reset.go @@ -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 +} diff --git a/api/notification/internal/server/server.go b/api/notification/internal/server/server.go new file mode 100644 index 0000000..b7675f8 --- /dev/null +++ b/api/notification/internal/server/server.go @@ -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) +} diff --git a/api/notification/main.go b/api/notification/main.go new file mode 100644 index 0000000..3e5bd4f --- /dev/null +++ b/api/notification/main.go @@ -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) +} diff --git a/api/notification/notification b/api/notification/notification new file mode 100755 index 0000000..3e20cfc Binary files /dev/null and b/api/notification/notification differ diff --git a/api/pkg/auth/taggable.go b/api/pkg/auth/taggable.go deleted file mode 100644 index 5519ebd..0000000 --- a/api/pkg/auth/taggable.go +++ /dev/null @@ -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) -} diff --git a/api/pkg/auth/taggableimp.go b/api/pkg/auth/taggableimp.go deleted file mode 100644 index 2784db9..0000000 --- a/api/pkg/auth/taggableimp.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/db/internal/mongo/indexable/examples.go b/api/pkg/db/internal/mongo/indexable/examples.go deleted file mode 100644 index c02607a..0000000 --- a/api/pkg/db/internal/mongo/indexable/examples.go +++ /dev/null @@ -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()) -} diff --git a/api/pkg/db/internal/mongo/indexable/indexable_test.go b/api/pkg/db/internal/mongo/indexable/indexable_test.go deleted file mode 100644 index 8bce3b6..0000000 --- a/api/pkg/db/internal/mongo/indexable/indexable_test.go +++ /dev/null @@ -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 - }) -} diff --git a/api/pkg/db/repository/builders.go b/api/pkg/db/repository/builders.go index f20d5b0..6035329 100644 --- a/api/pkg/db/repository/builders.go +++ b/api/pkg/db/repository/builders.go @@ -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) } diff --git a/api/pkg/db/tag/tag.go b/api/pkg/db/tag/tag.go deleted file mode 100644 index acce52e..0000000 --- a/api/pkg/db/tag/tag.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/attachment.go b/api/pkg/model/attachment.go deleted file mode 100644 index d497b54..0000000 --- a/api/pkg/model/attachment.go +++ /dev/null @@ -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) -} diff --git a/api/pkg/model/automation.go b/api/pkg/model/automation.go deleted file mode 100644 index 1211809..0000000 --- a/api/pkg/model/automation.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/comment.go b/api/pkg/model/comment.go deleted file mode 100644 index a12872b..0000000 --- a/api/pkg/model/comment.go +++ /dev/null @@ -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, - }, - } -} diff --git a/api/pkg/model/commentp.go b/api/pkg/model/commentp.go deleted file mode 100644 index fa306b0..0000000 --- a/api/pkg/model/commentp.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/customizable.go b/api/pkg/model/customizable.go deleted file mode 100644 index add42cb..0000000 --- a/api/pkg/model/customizable.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/filter.go b/api/pkg/model/filter.go deleted file mode 100644 index b2d9123..0000000 --- a/api/pkg/model/filter.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/invoice.go b/api/pkg/model/invoice.go deleted file mode 100644 index ff37ad8..0000000 --- a/api/pkg/model/invoice.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/pbinding.go b/api/pkg/model/pbinding.go deleted file mode 100644 index cdcd4d4..0000000 --- a/api/pkg/model/pbinding.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/pfilter.go b/api/pkg/model/pfilter.go deleted file mode 100644 index 9af5b51..0000000 --- a/api/pkg/model/pfilter.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/priority.go b/api/pkg/model/priority.go deleted file mode 100644 index bed03f3..0000000 --- a/api/pkg/model/priority.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/project.go b/api/pkg/model/project.go deleted file mode 100644 index a3415de..0000000 --- a/api/pkg/model/project.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/property.go b/api/pkg/model/property.go deleted file mode 100644 index 7e9402f..0000000 --- a/api/pkg/model/property.go +++ /dev/null @@ -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] -} diff --git a/api/pkg/model/reaction.go b/api/pkg/model/reaction.go deleted file mode 100644 index 992c05a..0000000 --- a/api/pkg/model/reaction.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/status.go b/api/pkg/model/status.go deleted file mode 100644 index 1ca89e2..0000000 --- a/api/pkg/model/status.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/step.go b/api/pkg/model/step.go deleted file mode 100644 index 4989a53..0000000 --- a/api/pkg/model/step.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/tag.go b/api/pkg/model/tag.go deleted file mode 100644 index 851011d..0000000 --- a/api/pkg/model/tag.go +++ /dev/null @@ -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"` -} diff --git a/api/pkg/model/task.go b/api/pkg/model/task.go deleted file mode 100644 index aa36993..0000000 --- a/api/pkg/model/task.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/team.go b/api/pkg/model/team.go deleted file mode 100644 index c12f045..0000000 --- a/api/pkg/model/team.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/value.go b/api/pkg/model/value.go deleted file mode 100644 index b681c29..0000000 --- a/api/pkg/model/value.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/value_test.go b/api/pkg/model/value_test.go deleted file mode 100644 index b92fb01..0000000 --- a/api/pkg/model/value_test.go +++ /dev/null @@ -1,1397 +0,0 @@ -package model - -import ( - "fmt" - "testing" - "time" - - "github.com/tech/sendico/pkg/mservice" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/bson/primitive" -) - -func createTestPermissionBound() PermissionBound { - pb := PermissionBound{ - PermissionRef: primitive.NewObjectID(), - } - pb.OrganizationRef = primitive.NewObjectID() - return pb -} - -func createTestRecordRef() ObjectRef { - return ObjectRef{ - Ref: primitive.NewObjectID(), - } -} - -func createTestPropertyScheme(propertyType PropertyType, key string) PropertySchema { - desc := "Test property scheme" - - ps := PropertySchema{ - PermissionBound: createTestPermissionBound(), - Describable: Describable{ - Name: key, - Description: &desc, - }, - Key: key, - Type: propertyType, - Multiplicity: Multiplicity{ - Mode: One, // Default to single values - }, - } - ps.ID = primitive.NewObjectID() - - // Set appropriate Props based on type - switch propertyType { - case PTString: - ps.Props = StringProps{} - case PTColor: - ps.Props = ColorProps{} - case PTInteger: - ps.Props = IntegerProps{} - case PTFloat: - ps.Props = FloatProps{} - case PTDateTime: - ps.Props = DateTimeProps{} - case PTMonetary: - ps.Props = MonetaryProps{} - case PTReference: - ps.Props = ReferenceProps{} - case PTObject: - ps.Props = ObjectProps{} - } - - return ps -} - -func createTestPropertySchemeMany(propertyType PropertyType, key string) PropertySchema { - desc := "Test property scheme" - - ps := PropertySchema{ - PermissionBound: createTestPermissionBound(), - Describable: Describable{ - Name: key, - Description: &desc, - }, - Key: key, - Type: propertyType, - Multiplicity: Multiplicity{ - Mode: Many, // Allow multiple values - }, - } - ps.ID = primitive.NewObjectID() - - // Set appropriate Props based on type - switch propertyType { - case PTString: - ps.Props = StringProps{} - case PTColor: - ps.Props = ColorProps{} - case PTInteger: - ps.Props = IntegerProps{} - case PTFloat: - ps.Props = FloatProps{} - case PTDateTime: - ps.Props = DateTimeProps{} - case PTMonetary: - ps.Props = MonetaryProps{} - case PTReference: - ps.Props = ReferenceProps{} - case PTObject: - ps.Props = ObjectProps{} - } - - return ps -} - -func TestValue(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTString, "test_string") - - value := Value{ - PermissionBound: scope, - Target: target, - Type: PTString, - Cardinality: One, - PropertySchemaRef: scheme.ID, - Values: SettingsT{"string": "test_value"}, - } - - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTString, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) -} - -func TestAsString(t *testing.T) { - t.Run("valid string value", func(t *testing.T) { - value := Value{ - Type: PTString, - Cardinality: One, - Values: SettingsT{"string": "hello"}, - } - - result, err := value.AsString() - - require.NoError(t, err) - assert.Equal(t, "hello", result) - }) - - t.Run("invalid type", func(t *testing.T) { - value := Value{ - Type: PTInteger, - Cardinality: One, - Values: SettingsT{"integer": 42}, - } - - _, err := value.AsString() - - assert.Error(t, err) - }) - - t.Run("invalid cardinality", func(t *testing.T) { - value := Value{ - Type: PTString, - Cardinality: Many, - Values: SettingsT{"strings": []string{"hello"}}, - } - - _, err := value.AsString() - - assert.Error(t, err) - }) -} - -func TestAsColor(t *testing.T) { - t.Run("valid color value", func(t *testing.T) { - value := Value{ - Type: PTColor, - Cardinality: One, - Values: SettingsT{"color": "#FF0000"}, - } - - result, err := value.AsColor() - - require.NoError(t, err) - assert.Equal(t, "#FF0000", result) - }) -} - -func TestAsInteger(t *testing.T) { - t.Run("valid integer value", func(t *testing.T) { - value := Value{ - Type: PTInteger, - Cardinality: One, - Values: SettingsT{"integer": int64(42)}, - } - - result, err := value.AsInteger() - - require.NoError(t, err) - assert.Equal(t, int64(42), result) - }) -} - -func TestAsFloat(t *testing.T) { - t.Run("valid float value", func(t *testing.T) { - value := Value{ - Type: PTFloat, - Cardinality: One, - Values: SettingsT{"float": 3.14}, - } - - result, err := value.AsFloat() - - require.NoError(t, err) - assert.Equal(t, 3.14, result) - }) -} - -func TestAsDateTime(t *testing.T) { - t.Run("valid datetime value", func(t *testing.T) { - now := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - value := Value{ - Type: PTDateTime, - Cardinality: One, - Values: SettingsT{"date_time": now}, - } - - result, err := value.AsDateTime() - - require.NoError(t, err) - assert.Equal(t, now, result) - }) -} - -func TestAsMonetary(t *testing.T) { - t.Run("valid monetary value", func(t *testing.T) { - money := Money{ - Amount: primitive.NewDecimal128(10000, 0), // $100.00 - Currency: CurrencyUSD, - } - value := Value{ - Type: PTMonetary, - Cardinality: One, - Values: SettingsT{"monetary": money}, - } - - result, err := value.AsMonetary() - - require.NoError(t, err) - assert.Equal(t, money, result) - }) -} - -func TestAsReference(t *testing.T) { - t.Run("valid reference value", func(t *testing.T) { - ref := primitive.NewObjectID() - value := Value{ - Type: PTReference, - Cardinality: One, - Values: SettingsT{"reference": ref}, - } - - result, err := value.AsReference() - - require.NoError(t, err) - assert.Equal(t, ref, result) - }) -} - -func TestAsObject(t *testing.T) { - t.Run("valid object value", func(t *testing.T) { - obj := Object{ - "field1": Value{ - Type: PTString, - Cardinality: One, - Values: SettingsT{"string": "value1"}, - }, - } - value := Value{ - Type: PTObject, - Cardinality: One, - Values: SettingsT{"object": obj}, - } - - result, err := value.AsObject() - - require.NoError(t, err) - assert.Equal(t, obj, result) - }) -} - -func TestAsStrings(t *testing.T) { - t.Run("valid strings value", func(t *testing.T) { - value := Value{ - Type: PTString, - Cardinality: Many, - Values: SettingsT{"strings": []string{"hello", "world"}}, - } - - result, err := value.AsStrings() - - require.NoError(t, err) - assert.Equal(t, []string{"hello", "world"}, result) - }) -} - -func TestAsColors(t *testing.T) { - t.Run("valid colors value", func(t *testing.T) { - value := Value{ - Type: PTColor, - Cardinality: Many, - Values: SettingsT{"colors": []string{"#FF0000", "#00FF00"}}, - } - - result, err := value.AsColors() - - require.NoError(t, err) - assert.Equal(t, []string{"#FF0000", "#00FF00"}, result) - }) -} - -func TestAsIntegers(t *testing.T) { - t.Run("valid integers value", func(t *testing.T) { - value := Value{ - Type: PTInteger, - Cardinality: Many, - Values: SettingsT{"integers": []int64{1, 2, 3}}, - } - - result, err := value.AsIntegers() - - require.NoError(t, err) - assert.Equal(t, []int64{1, 2, 3}, result) - }) -} - -func TestAsFloats(t *testing.T) { - t.Run("valid floats value", func(t *testing.T) { - value := Value{ - Type: PTFloat, - Cardinality: Many, - Values: SettingsT{"floats": []float64{1.1, 2.2, 3.3}}, - } - - result, err := value.AsFloats() - - require.NoError(t, err) - assert.Equal(t, []float64{1.1, 2.2, 3.3}, result) - }) -} - -func TestAsDateTimes(t *testing.T) { - t.Run("valid datetimes value", func(t *testing.T) { - now1 := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - now2 := time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC) - value := Value{ - Type: PTDateTime, - Cardinality: Many, - Values: SettingsT{"date_times": []time.Time{now1, now2}}, - } - - result, err := value.AsDateTimes() - - require.NoError(t, err) - assert.Equal(t, []time.Time{now1, now2}, result) - }) -} - -func TestAsMonetaries(t *testing.T) { - t.Run("valid monetaries value", func(t *testing.T) { - money1 := Money{Amount: primitive.NewDecimal128(10000, 0), Currency: CurrencyUSD} - money2 := Money{Amount: primitive.NewDecimal128(20000, 0), Currency: CurrencyUSD} - value := Value{ - Type: PTMonetary, - Cardinality: Many, - Values: SettingsT{"monetaries": []Money{money1, money2}}, - } - - result, err := value.AsMonetaries() - - require.NoError(t, err) - assert.Equal(t, []Money{money1, money2}, result) - }) -} - -func TestAsReferences(t *testing.T) { - t.Run("valid references value", func(t *testing.T) { - ref1 := primitive.NewObjectID() - ref2 := primitive.NewObjectID() - value := Value{ - Type: PTReference, - Cardinality: Many, - Values: SettingsT{"references": []primitive.ObjectID{ref1, ref2}}, - } - - result, err := value.AsReferences() - - require.NoError(t, err) - assert.Equal(t, []primitive.ObjectID{ref1, ref2}, result) - }) -} - -func TestAsObjects(t *testing.T) { - t.Run("valid objects value", func(t *testing.T) { - obj1 := Object{"field1": Value{Type: PTString, Cardinality: One, Values: SettingsT{"string": "value1"}}} - obj2 := Object{"field2": Value{Type: PTString, Cardinality: One, Values: SettingsT{"string": "value2"}}} - value := Value{ - Type: PTObject, - Cardinality: Many, - Values: SettingsT{"objects": []Object{obj1, obj2}}, - } - - result, err := value.AsObjects() - - require.NoError(t, err) - assert.Equal(t, []Object{obj1, obj2}, result) - }) -} - -func TestNewStringValue(t *testing.T) { - t.Run("valid string value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTString, "test_string") - - value, err := NewStringValue(scope, target, scheme, "hello") - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTString, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"string": "hello"}, value.Values) - }) - - t.Run("invalid type", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTInteger, "test_int") - - _, err := NewStringValue(scope, target, scheme, "hello") - - assert.Error(t, err) - }) -} - -func TestNewStringsValue(t *testing.T) { - t.Run("valid strings value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTString, "test_strings") - - value, err := NewStringsValue(scope, target, scheme, []string{"hello", "world"}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTString, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"strings": []string{"hello", "world"}}, value.Values) - }) -} - -func TestNewColorValue(t *testing.T) { - t.Run("valid color value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTColor, "test_color") - - value, err := NewColorValue(scope, target, scheme, "#FF0000") - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTColor, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"color": "#FF0000"}, value.Values) - }) -} - -func TestNewColorsValue(t *testing.T) { - t.Run("valid colors value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTColor, "test_colors") - - value, err := NewColorsValue(scope, target, scheme, []string{"#FF0000", "#00FF00"}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTColor, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"colors": []string{"#FF0000", "#00FF00"}}, value.Values) - }) -} - -func TestNewIntegerValue(t *testing.T) { - t.Run("valid integer value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTInteger, "test_int") - - value, err := NewIntegerValue(scope, target, scheme, 42) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTInteger, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"integer": int64(42)}, value.Values) - }) -} - -func TestNewIntegersValue(t *testing.T) { - t.Run("valid integers value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTInteger, "test_ints") - - value, err := NewIntegersValue(scope, target, scheme, []int64{1, 2, 3}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTInteger, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"integers": []int64{1, 2, 3}}, value.Values) - }) -} - -func TestNewFloatValue(t *testing.T) { - t.Run("valid float value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTFloat, "test_float") - - value, err := NewFloatValue(scope, target, scheme, 3.14) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTFloat, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"float": 3.14}, value.Values) - }) -} - -func TestNewFloatsValue(t *testing.T) { - t.Run("valid floats value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTFloat, "test_floats") - - value, err := NewFloatsValue(scope, target, scheme, []float64{1.1, 2.2, 3.3}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTFloat, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"floats": []float64{1.1, 2.2, 3.3}}, value.Values) - }) -} - -func TestNewDateTimeValue(t *testing.T) { - t.Run("valid datetime value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTDateTime, "test_datetime") - now := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - - value, err := NewDateTimeValue(scope, target, scheme, now) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTDateTime, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"date_time": now}, value.Values) - }) -} - -func TestNewDateTimesValue(t *testing.T) { - t.Run("valid datetimes value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTDateTime, "test_datetimes") - now1 := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) - now2 := time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC) - - value, err := NewDateTimesValue(scope, target, scheme, []time.Time{now1, now2}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTDateTime, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"date_times": []time.Time{now1, now2}}, value.Values) - }) -} - -func TestNewMonetaryValue(t *testing.T) { - t.Run("valid monetary value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTMonetary, "test_monetary") - money := Money{ - Amount: primitive.NewDecimal128(10000, 0), // $100.00 - Currency: CurrencyUSD, - } - - value, err := NewMonetaryValue(scope, target, scheme, money, CurrencyUSD) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTMonetary, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"monetary": money}, value.Values) - }) -} - -func TestNewMonetariesValue(t *testing.T) { - t.Run("valid monetaries value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTMonetary, "test_monetaries") - money1 := Money{Amount: primitive.NewDecimal128(10000, 0), Currency: CurrencyUSD} - money2 := Money{Amount: primitive.NewDecimal128(20000, 0), Currency: CurrencyUSD} - - value, err := NewMonetariesValue(scope, target, scheme, []Money{money1, money2}, CurrencyUSD) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTMonetary, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"monetaries": []Money{money1, money2}}, value.Values) - }) -} - -func TestNewReferenceValue(t *testing.T) { - t.Run("valid reference value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTReference, "test_reference") - ref := primitive.NewObjectID() - - value, err := NewReferenceValue(scope, target, scheme, ref, func(resource mservice.Type, id primitive.ObjectID, filter bson.M) (bool, error) { return true, nil }) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTReference, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"reference": ref}, value.Values) - }) -} - -func TestNewReferencesValue(t *testing.T) { - t.Run("valid references value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTReference, "test_references") - ref1 := primitive.NewObjectID() - ref2 := primitive.NewObjectID() - - value, err := NewReferencesValue(scope, target, scheme, []primitive.ObjectID{ref1, ref2}, func(resource mservice.Type, id primitive.ObjectID, filter bson.M) (bool, error) { return true, nil }) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTReference, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"references": []primitive.ObjectID{ref1, ref2}}, value.Values) - }) -} - -func TestNewObjectValue(t *testing.T) { - t.Run("valid object value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertyScheme(PTObject, "test_object") - obj := Object{ - "field1": Value{ - Type: PTString, - Cardinality: One, - Values: SettingsT{"string": "value1"}, - }, - } - - value, err := NewObjectValue(scope, target, scheme, obj) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTObject, value.Type) - assert.Equal(t, One, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"object": obj}, value.Values) - }) -} - -func TestNewObjectsValue(t *testing.T) { - t.Run("valid objects value", func(t *testing.T) { - scope := createTestPermissionBound() - target := createTestRecordRef() - scheme := createTestPropertySchemeMany(PTObject, "test_objects") - obj1 := Object{"field1": Value{Type: PTString, Cardinality: One, Values: SettingsT{"string": "value1"}}} - obj2 := Object{"field2": Value{Type: PTString, Cardinality: One, Values: SettingsT{"string": "value2"}}} - - value, err := NewObjectsValue(scope, target, scheme, []Object{obj1, obj2}) - - require.NoError(t, err) - assert.Equal(t, scope, value.PermissionBound) - assert.Equal(t, target, value.Target) - assert.Equal(t, PTObject, value.Type) - assert.Equal(t, Many, value.Cardinality) - assert.Equal(t, scheme.ID, value.PropertySchemaRef) - assert.Equal(t, SettingsT{"objects": []Object{obj1, obj2}}, value.Values) - }) -} - -// ---------------------------- -// Serialization/Deserialization Tests -// ---------------------------- - -func TestValueBSONSerialization(t *testing.T) { - t.Run("string value BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"string": "hello world"}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.PermissionBound, restored.PermissionBound) - assert.Equal(t, original.Target, restored.Target) - assert.Equal(t, original.Type, restored.Type) - assert.Equal(t, original.Cardinality, restored.Cardinality) - assert.Equal(t, original.PropertySchemaRef, restored.PropertySchemaRef) - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - str, err := restored.AsString() - require.NoError(t, err) - assert.Equal(t, "hello world", str) - }) - - t.Run("strings value BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"strings": []string{"hello", "world", "test"}}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - strings, err := restored.AsStrings() - require.NoError(t, err) - assert.Equal(t, []string{"hello", "world", "test"}, strings) - }) - - t.Run("integer value BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTInteger, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"integer": int64(42)}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsInteger() - require.NoError(t, err) - assert.Equal(t, int64(42), val) - }) - - t.Run("float value BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTFloat, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"float": 3.14159}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsFloat() - require.NoError(t, err) - assert.Equal(t, 3.14159, val) - }) - - t.Run("datetime value BSON round-trip", func(t *testing.T) { - // Use millisecond precision to match primitive.DateTime - now := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).Truncate(time.Millisecond) - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTDateTime, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"date_time": now}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare all fields except Values (which may have timezone differences) - assert.Equal(t, original.PermissionBound, restored.PermissionBound) - assert.Equal(t, original.Target, restored.Target) - assert.Equal(t, original.Type, restored.Type) - assert.Equal(t, original.Cardinality, restored.Cardinality) - assert.Equal(t, original.PropertySchemaRef, restored.PropertySchemaRef) - - // Test that we can still access the value - val, err := restored.AsDateTime() - require.NoError(t, err) - // Compare only the actual time value, not timezone metadata - assert.Equal(t, now.Unix(), val.Unix()) - }) - - t.Run("monetary value BSON round-trip", func(t *testing.T) { - money := Money{ - Amount: primitive.NewDecimal128(10000, 0), // $100.00 - Currency: CurrencyUSD, - } - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTMonetary, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"monetary": money}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsMonetary() - require.NoError(t, err) - assert.Equal(t, money, val) - }) - - t.Run("reference value BSON round-trip", func(t *testing.T) { - ref := primitive.NewObjectID() - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTReference, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"reference": ref}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsReference() - require.NoError(t, err) - assert.Equal(t, ref, val) - }) - - t.Run("color value BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTColor, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"color": "#FF0000"}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsColor() - require.NoError(t, err) - assert.Equal(t, "#FF0000", val) - }) - - t.Run("object value BSON round-trip", func(t *testing.T) { - obj := Object{ - "field1": Value{ - Type: PTString, - Cardinality: One, - Values: SettingsT{"string": "value1"}, - }, - "field2": Value{ - Type: PTInteger, - Cardinality: One, - Values: SettingsT{"integer": int64(42)}, - }, - } - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTObject, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"object": obj}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - val, err := restored.AsObject() - require.NoError(t, err) - assert.Equal(t, obj, val) - }) -} - -func TestValueJSONSerialization(t *testing.T) { - t.Run("string value JSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: One, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"string": "hello world"}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.PermissionBound, restored.PermissionBound) - assert.Equal(t, original.Target, restored.Target) - assert.Equal(t, original.Type, restored.Type) - assert.Equal(t, original.Cardinality, restored.Cardinality) - assert.Equal(t, original.PropertySchemaRef, restored.PropertySchemaRef) - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the value - str, err := restored.AsString() - require.NoError(t, err) - assert.Equal(t, "hello world", str) - }) - - t.Run("integers value JSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTInteger, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"integers": []int64{1, 2, 3, 4, 5}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsIntegers() - require.NoError(t, err) - assert.Equal(t, []int64{1, 2, 3, 4, 5}, vals) - }) - - t.Run("floats value JSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTFloat, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"floats": []float64{1.1, 2.2, 3.3}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsFloats() - require.NoError(t, err) - assert.Equal(t, []float64{1.1, 2.2, 3.3}, vals) - }) - - t.Run("datetimes value JSON round-trip", func(t *testing.T) { - // Use millisecond precision to match primitive.DateTime - now1 := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC).Truncate(time.Millisecond) - now2 := time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC).Truncate(time.Millisecond) - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTDateTime, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"date_times": []time.Time{now1, now2}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare all fields except Values (which may have timezone differences) - assert.Equal(t, original.PermissionBound, restored.PermissionBound) - assert.Equal(t, original.Target, restored.Target) - assert.Equal(t, original.Type, restored.Type) - assert.Equal(t, original.Cardinality, restored.Cardinality) - assert.Equal(t, original.PropertySchemaRef, restored.PropertySchemaRef) - - // Test that we can still access the values - vals, err := restored.AsDateTimes() - require.NoError(t, err) - // Compare only the actual time values, not timezone metadata - assert.Equal(t, 2, len(vals)) - assert.Equal(t, now1.Unix(), vals[0].Unix()) - assert.Equal(t, now2.Unix(), vals[1].Unix()) - }) - - t.Run("monetaries value JSON round-trip", func(t *testing.T) { - money1 := Money{Amount: primitive.NewDecimal128(10000, 0), Currency: CurrencyUSD} - money2 := Money{Amount: primitive.NewDecimal128(20000, 0), Currency: CurrencyUSD} - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTMonetary, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"monetaries": []Money{money1, money2}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsMonetaries() - require.NoError(t, err) - assert.Equal(t, []Money{money1, money2}, vals) - }) - - t.Run("references value JSON round-trip", func(t *testing.T) { - ref1 := primitive.NewObjectID() - ref2 := primitive.NewObjectID() - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTReference, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"references": []primitive.ObjectID{ref1, ref2}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsReferences() - require.NoError(t, err) - assert.Equal(t, []primitive.ObjectID{ref1, ref2}, vals) - }) - - t.Run("colors value JSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTColor, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"colors": []string{"#FF0000", "#00FF00", "#0000FF"}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsColors() - require.NoError(t, err) - assert.Equal(t, []string{"#FF0000", "#00FF00", "#0000FF"}, vals) - }) - - t.Run("objects value JSON round-trip", func(t *testing.T) { - obj1 := Object{"field1": Value{Type: PTString, Cardinality: One, Values: SettingsT{"string": "value1"}}} - obj2 := Object{"field2": Value{Type: PTInteger, Cardinality: One, Values: SettingsT{"integer": int64(42)}}} - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTObject, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"objects": []Object{obj1, obj2}}, - } - - // Marshal to JSON - jsonData, err := bson.MarshalExtJSON(original, true, false) - require.NoError(t, err) - - // Unmarshal from JSON - var restored Value - err = bson.UnmarshalExtJSON(jsonData, true, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the values - vals, err := restored.AsObjects() - require.NoError(t, err) - assert.Equal(t, []Object{obj1, obj2}, vals) - }) -} - -func TestValueSerializationEdgeCases(t *testing.T) { - t.Run("empty values BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"strings": []string{}}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the empty values - vals, err := restored.AsStrings() - require.NoError(t, err) - assert.Equal(t, []string{}, vals) - }) - - t.Run("nil values BSON round-trip", func(t *testing.T) { - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"strings": []string(nil)}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the nil values - vals, err := restored.AsStrings() - require.NoError(t, err) - assert.Nil(t, vals) - }) - - t.Run("large values BSON round-trip", func(t *testing.T) { - // Create a large slice of strings - largeStrings := make([]string, 1000) - for i := 0; i < 1000; i++ { - largeStrings[i] = fmt.Sprintf("string_%d", i) - } - - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"strings": largeStrings}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the large values - vals, err := restored.AsStrings() - require.NoError(t, err) - assert.Equal(t, largeStrings, vals) - }) - - t.Run("special characters BSON round-trip", func(t *testing.T) { - specialStrings := []string{ - "hello world", - "привет мир", // Cyrillic - "你好世界", // Chinese - "مرحبا بالعالم", // Arabic - "🚀🌟💫", // Emojis - "line\nbreak\ttab", - "quotes\"and'apostrophes", - "backslash\\and/slash", - } - - original := Value{ - PermissionBound: createTestPermissionBound(), - Target: createTestRecordRef(), - Type: PTString, - Cardinality: Many, - PropertySchemaRef: primitive.NewObjectID(), - Values: SettingsT{"strings": specialStrings}, - } - - // Marshal to BSON - bsonData, err := bson.Marshal(original) - require.NoError(t, err) - - // Unmarshal from BSON - var restored Value - err = bson.Unmarshal(bsonData, &restored) - require.NoError(t, err) - - // Compare - assert.Equal(t, original.Values, restored.Values) - - // Test that we can still access the special character values - vals, err := restored.AsStrings() - require.NoError(t, err) - assert.Equal(t, specialStrings, vals) - }) -} diff --git a/api/pkg/model/workflow.go b/api/pkg/model/workflow.go deleted file mode 100644 index 1f2969c..0000000 --- a/api/pkg/model/workflow.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/model/workspace.go b/api/pkg/model/workspace.go deleted file mode 100644 index 517c17e..0000000 --- a/api/pkg/model/workspace.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/mutil/helpers/integration_test.go b/api/pkg/mutil/helpers/integration_test.go deleted file mode 100644 index 88339f2..0000000 --- a/api/pkg/mutil/helpers/integration_test.go +++ /dev/null @@ -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") -} diff --git a/api/pkg/mutil/helpers/internal/simple_internal_test.go b/api/pkg/mutil/helpers/internal/simple_internal_test.go index 81d8ae5..6f67d64 100644 --- a/api/pkg/mutil/helpers/internal/simple_internal_test.go +++ b/api/pkg/mutil/helpers/internal/simple_internal_test.go @@ -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") - } -} diff --git a/api/pkg/mutil/helpers/internal/task_manager_business_test.go b/api/pkg/mutil/helpers/internal/task_manager_business_test.go deleted file mode 100644 index 4324f06..0000000 --- a/api/pkg/mutil/helpers/internal/task_manager_business_test.go +++ /dev/null @@ -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) - } - }) -} diff --git a/api/pkg/mutil/helpers/internal/taskmanager.go b/api/pkg/mutil/helpers/internal/taskmanager.go deleted file mode 100644 index d7dc462..0000000 --- a/api/pkg/mutil/helpers/internal/taskmanager.go +++ /dev/null @@ -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") -} diff --git a/api/pkg/mutil/helpers/simple_test.go b/api/pkg/mutil/helpers/simple_test.go index da38531..a54425f 100644 --- a/api/pkg/mutil/helpers/simple_test.go +++ b/api/pkg/mutil/helpers/simple_test.go @@ -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") - } -} diff --git a/api/pkg/mutil/helpers/taskmanager.go b/api/pkg/mutil/helpers/taskmanager.go deleted file mode 100644 index ed4d77e..0000000 --- a/api/pkg/mutil/helpers/taskmanager.go +++ /dev/null @@ -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 -} diff --git a/api/pkg/mutil/helpers/taskmanager_factory.go b/api/pkg/mutil/helpers/taskmanager_factory.go deleted file mode 100644 index 6ab7e66..0000000 --- a/api/pkg/mutil/helpers/taskmanager_factory.go +++ /dev/null @@ -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) -} diff --git a/api/server/interface/api/srequest/priority.go b/api/server/interface/api/srequest/priority.go deleted file mode 100644 index 2939ebe..0000000 --- a/api/server/interface/api/srequest/priority.go +++ /dev/null @@ -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"` -} diff --git a/api/server/interface/api/srequest/project.go b/api/server/interface/api/srequest/project.go deleted file mode 100644 index 09f082c..0000000 --- a/api/server/interface/api/srequest/project.go +++ /dev/null @@ -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 diff --git a/api/server/interface/api/srequest/project_delete.go b/api/server/interface/api/srequest/project_delete.go deleted file mode 100644 index 386dbeb..0000000 --- a/api/server/interface/api/srequest/project_delete.go +++ /dev/null @@ -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 -} diff --git a/api/server/interface/api/srequest/signup.go b/api/server/interface/api/srequest/signup.go index e9147be..2f96c04 100644 --- a/api/server/interface/api/srequest/signup.go +++ b/api/server/interface/api/srequest/signup.go @@ -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"` } diff --git a/api/server/interface/api/srequest/signup_test.go b/api/server/interface/api/srequest/signup_test.go index f53e063..0b284ef 100644 --- a/api/server/interface/api/srequest/signup_test.go +++ b/api/server/interface/api/srequest/signup_test.go @@ -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) -} diff --git a/api/server/interface/api/srequest/status.go b/api/server/interface/api/srequest/status.go deleted file mode 100644 index 5264f8c..0000000 --- a/api/server/interface/api/srequest/status.go +++ /dev/null @@ -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"` -} diff --git a/api/server/interface/api/sresponse/commentp.go b/api/server/interface/api/sresponse/commentp.go deleted file mode 100644 index bce9fc9..0000000 --- a/api/server/interface/api/sresponse/commentp.go +++ /dev/null @@ -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}, - }, - ) -} diff --git a/api/server/interface/api/sresponse/projects.go b/api/server/interface/api/sresponse/projects.go deleted file mode 100644 index 965aba7..0000000 --- a/api/server/interface/api/sresponse/projects.go +++ /dev/null @@ -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, - }) -} diff --git a/api/server/interface/api/sresponse/statuses.go b/api/server/interface/api/sresponse/statuses.go deleted file mode 100644 index 6b6ae0d..0000000 --- a/api/server/interface/api/sresponse/statuses.go +++ /dev/null @@ -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) -} diff --git a/api/server/internal/server/accountapiimp/signup_integration_test.go b/api/server/internal/server/accountapiimp/signup_integration_test.go index 515f3f9..54baae2 100644 --- a/api/server/internal/server/accountapiimp/signup_integration_test.go +++ b/api/server/internal/server/accountapiimp/signup_integration_test.go @@ -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", }, diff --git a/api/server/internal/server/accountapiimp/signup_test.go b/api/server/internal/server/accountapiimp/signup_test.go index 91b2918..276ecac 100644 --- a/api/server/internal/server/accountapiimp/signup_test.go +++ b/api/server/internal/server/accountapiimp/signup_test.go @@ -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{