move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View File

@@ -0,0 +1,130 @@
package aapitemplate
import (
"github.com/tech/sendico/server/interface/api/sresponse"
)
type HandlerResolver func(sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc
type Config interface {
WithNoCreate() Config
WithCreateHandler(handler sresponse.AccountHandlerFunc) Config
WithNoList() Config
WithListHandler(handler sresponse.AccountHandlerFunc) Config
WithNoGet() Config
WithGetHandler(handler sresponse.AccountHandlerFunc) Config
WithNoUpdate() Config
WithUpdateHandler(handler sresponse.AccountHandlerFunc) Config
WithNoDelete() Config
WithDeleteHandler(handler sresponse.AccountHandlerFunc) Config
WithReorderHandler(reorder ReorderConfig) Config
}
type AAPIConfig struct {
CreateResolver HandlerResolver
ListResolver HandlerResolver
GetResolver HandlerResolver
UpdateResolver HandlerResolver
DeleteResolver HandlerResolver
ArchiveResolver HandlerResolver
Reorder *ReorderConfig
}
// WithNoCreate disables the create endpoint by replacing its resolver.
func (cfg *AAPIConfig) WithNoCreate() *AAPIConfig {
cfg.CreateResolver = disableResolver
return cfg
}
// WithCreateHandler overrides the create endpoint by replacing its resolver.
func (cfg *AAPIConfig) WithCreateHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.CreateResolver = overrideResolver(handler)
return cfg
}
// WithNoList disables the list endpoint.
func (cfg *AAPIConfig) WithNoList() *AAPIConfig {
cfg.ListResolver = disableResolver
return cfg
}
// WithListHandler overrides the list endpoint.
func (cfg *AAPIConfig) WithListHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.ListResolver = overrideResolver(handler)
return cfg
}
// WithNoGet disables the get endpoint.
func (cfg *AAPIConfig) WithNoGet() *AAPIConfig {
cfg.GetResolver = disableResolver
return cfg
}
// WithGetHandler overrides the get endpoint.
func (cfg *AAPIConfig) WithGetHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.GetResolver = overrideResolver(handler)
return cfg
}
// WithNoUpdate disables the update endpoint.
func (cfg *AAPIConfig) WithNoUpdate() *AAPIConfig {
cfg.UpdateResolver = disableResolver
return cfg
}
// WithUpdateHandler overrides the update endpoint.
func (cfg *AAPIConfig) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.UpdateResolver = overrideResolver(handler)
return cfg
}
// WithNoDelete disables the delete endpoint.
func (cfg *AAPIConfig) WithNoDelete() *AAPIConfig {
cfg.DeleteResolver = disableResolver
return cfg
}
// WithDeleteHandler overrides the delete endpoint.
func (cfg *AAPIConfig) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.DeleteResolver = overrideResolver(handler)
return cfg
}
func (cfg *AAPIConfig) WithNoArchive() *AAPIConfig {
cfg.ArchiveResolver = disableResolver
return cfg
}
func (cfg *AAPIConfig) WithArchiveHandler(handler sresponse.AccountHandlerFunc) *AAPIConfig {
cfg.ArchiveResolver = overrideResolver(handler)
return cfg
}
// defaultResolver returns the default handler unchanged.
func defaultResolver(defaultHandler sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return defaultHandler
}
// disableResolver always returns nil, disabling the endpoint.
func disableResolver(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return nil
}
// overrideResolver returns a resolver that always returns the given custom handler.
func overrideResolver(custom sresponse.AccountHandlerFunc) HandlerResolver {
return func(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return custom
}
}
func NewConfig() *AAPIConfig {
return &AAPIConfig{
CreateResolver: defaultResolver,
ListResolver: defaultResolver,
GetResolver: defaultResolver,
UpdateResolver: defaultResolver,
DeleteResolver: defaultResolver,
ArchiveResolver: defaultResolver,
Reorder: nil,
}
}

View File

@@ -0,0 +1,31 @@
package aapitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var object T
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
a.Logger.Warn("Failed to decode object when creating", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
if err := a.DB.Create(r.Context(), *account.GetID(), &object); err != nil {
a.Logger.Warn("Error creating object", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.nconfig.CreateNotification(&object, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send creation notification", zap.Error(err), mzap.StorableRef(account))
}
return a.ObjectCreated(&object, accessToken)
}

View File

@@ -0,0 +1,21 @@
package aapitemplate
import (
"context"
"github.com/tech/sendico/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB[T any] interface {
Create(ctx context.Context, accountRef bson.ObjectID, object *T) error
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result *T) error
Update(ctx context.Context, accountRef bson.ObjectID, object *T) error
Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
List(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]T, error)
}
type ReorderDB interface {
Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error
}

View File

@@ -0,0 +1,53 @@
package aapitemplate
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) deleteImp(ctx context.Context, account *model.Account, objectRef bson.ObjectID) error {
if err := a.DB.Delete(ctx, *account.GetID(), objectRef); err != nil {
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("object_ref", objectRef))
return err
}
return nil
}
func (a *AccountAPI[T]) delete(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
objectRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var objPtr *T
if a.nconfig.NeedDeleteNotification {
var object T
if err := a.DB.Get(r.Context(), *account.GetID(), objectRef, &object); err != nil {
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
} else {
objPtr = &object
}
}
if err := a.deleteImp(r.Context(), account, objectRef); err != nil {
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
return response.Auto(a.Logger, a.Name(), err)
}
if objPtr != nil {
if err := a.nconfig.DeleteNotification(objPtr, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send deletion notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
}
}
return a.Objects([]T{}, accessToken)
}

View File

@@ -0,0 +1,29 @@
package aapitemplate
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
objectRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var object T
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
a.Logger.Warn("Failed to fetch object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
return response.Auto(a.Logger, a.Name(), err)
}
return a.Object(&object, accessToken)
}

View File

@@ -0,0 +1,33 @@
package aapitemplate
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
organizationRef, err := a.Orgph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Orgph, r))
return response.BadReference(a.Logger, a.Name(), a.Orgph.Name(), a.Orgph.GetID(r), err)
}
objects, err := a.DB.List(ctx, *account.GetID(), organizationRef)
if err != nil {
if !errors.Is(err, merrors.ErrNoData) {
a.Logger.Warn("Failed to list objects", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.Logger, a.Name(), err)
} else {
a.Logger.Debug("No objects available", zap.Error(err), mzap.StorableRef(account))
}
}
return a.Objects(objects, accessToken)
}

View File

@@ -0,0 +1,88 @@
package aapitemplate
import (
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
model "github.com/tech/sendico/pkg/model/notification"
"go.mongodb.org/mongo-driver/v2/bson"
)
// NotificationHandler is a function that processes an object of type T and returns an error.
type NotificationHandler[T any] func(template T, actorAccountRef bson.ObjectID) error
// sinkNotification is the default no-op strategy.
func sinkNotification[T any](_ T, _ bson.ObjectID) error {
return nil
}
// NotificationConfig manages notifications for Create, Update, and Delete operations.
type NotificationConfig[T any] struct {
producer messaging.Producer
// The factory now receives a NotificationAction so it knows which event is being processed.
factory func(template T, actorAccountRef bson.ObjectID, t model.NotificationAction) notifications.Envelope
CreateNotification NotificationHandler[T]
UpdateNotification NotificationHandler[T]
NeedArchiveNotification bool
ArchiveNotification NotificationHandler[T]
NeedDeleteNotification bool
DeleteNotification NotificationHandler[T]
}
// NewNotificationConfig creates a new NotificationConfig with default (no-op) strategies.
func NewNotificationConfig[T any](producer messaging.Producer) *NotificationConfig[T] {
return &NotificationConfig[T]{
producer: producer,
factory: nil, // no factory by default
CreateNotification: sinkNotification[T],
UpdateNotification: sinkNotification[T],
ArchiveNotification: sinkNotification[T],
NeedArchiveNotification: false,
DeleteNotification: sinkNotification[T],
NeedDeleteNotification: false,
}
}
// WithNotifications sets the notification factory and switches all endpoints to the sending strategy.
func (nc *NotificationConfig[T]) WithNotifications(factory func(template T, actorAccountRef bson.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
nc.factory = factory
// Build sending functions for each notification type.
nc.CreateNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
}
nc.UpdateNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
}
nc.ArchiveNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
}
nc.NeedArchiveNotification = true
nc.DeleteNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
}
nc.NeedDeleteNotification = true
return nc
}
// WithNoCreateNotification disables the create notification.
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
nc.CreateNotification = sinkNotification[T]
return nc
}
// WithNoUpdateNotification disables the update notification.
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
nc.UpdateNotification = sinkNotification[T]
return nc
}
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
nc.ArchiveNotification = sinkNotification[T]
return nc
}
// WithNoDeleteNotification disables the delete notification.
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
nc.DeleteNotification = sinkNotification[T]
nc.NeedDeleteNotification = false
return nc
}

View File

@@ -0,0 +1,33 @@
package aapitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/server/interface/api/srequest"
)
type ReorderRequestProcessor func(r *http.Request) (*srequest.ReorderX, builder.Query, error)
type ReorderConfig struct {
DB ReorderDB
ReqProcessor ReorderRequestProcessor
}
func (cfg *AAPIConfig) WithReorderHandler(reorder ReorderConfig) *AAPIConfig {
cfg.Reorder = &reorder
if cfg.Reorder.ReqProcessor == nil {
cfg.Reorder.ReqProcessor = defaultRequestProcessor
}
return cfg
}
func defaultRequestProcessor(r *http.Request) (*srequest.ReorderX, builder.Query, error) {
var req srequest.ReorderXDefault
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, nil, err
}
return &req.ReorderX, repository.OrgFilter(req.ParentRef), nil
}

View File

@@ -0,0 +1,33 @@
package aapitemplate
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) reorder(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing reorder request...")
req, filter, err := a.config.Reorder.ReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode tasks reorder request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Moving objects", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("new_index", req.To))
if _, err := a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
// reorder is not atomic, so wrappping into transaction
return nil, a.config.Reorder.DB.Reorder(ctx, account.ID, req.ObjectRef, req.To, filter)
}); err != nil {
a.Logger.Warn("Failed to reorder tasks", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("to", req.To))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Reorder request processing complete")
return response.Success(a.Logger)
}

View File

@@ -0,0 +1,19 @@
package aapitemplate
import (
"net/http"
"github.com/tech/sendico/server/interface/api/sresponse"
)
func (a *AccountAPI[T]) Objects(items []T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectsAuth(a.Logger, items, accessToken, a.Name())
}
func (a *AccountAPI[T]) Object(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectAuth(a.Logger, item, accessToken, a.Name())
}
func (a *AccountAPI[T]) ObjectCreated(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectAuthCreated(a.Logger, item, accessToken, a.Name())
}

View File

@@ -0,0 +1,181 @@
package aapitemplate
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
model "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type AccountAPI[T any] struct {
Logger mlogger.Logger
DB DB[T]
Oph mutil.ParamHelper // object param handler
Orgph mutil.ParamHelper // organization param handler
a eapi.API
config *AAPIConfig
nconfig *NotificationConfig[*T]
resource mservice.Type
}
func (a *AccountAPI[_]) Name() mservice.Type {
return a.resource
}
func (_ *AccountAPI[_]) Finish(_ context.Context) error {
return nil
}
func (a *AccountAPI[T]) Build() *AccountAPI[T] {
createHandler := a.config.CreateResolver(a.create)
if createHandler != nil {
a.a.Register().AccountHandler(a.Name(), "/", api.Post, createHandler)
}
listHandler := a.config.ListResolver(a.list)
if listHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Orgph.AddRef("/list"), api.Get, listHandler)
}
getHandler := a.config.GetResolver(a.get)
if getHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Get, getHandler)
}
updateHandler := a.config.UpdateResolver(a.update)
if updateHandler != nil {
a.a.Register().AccountHandler(a.Name(), "/", api.Put, updateHandler)
}
deleteHandler := a.config.DeleteResolver(a.delete)
if deleteHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Delete, deleteHandler)
}
if a.config.Reorder != nil {
a.a.Register().AccountHandler(a.Name(), "/reorder", api.Post, a.reorder)
}
return a
}
func (a *AccountAPI[T]) WithNotifications(factory func(template *T, actorAccountRef bson.ObjectID, t model.NotificationAction) notifications.Envelope) *AccountAPI[T] {
a.nconfig.WithNotifications(factory)
a.Logger.Info("Notificatons handler installed")
return a
}
// WithNoCreateNotification disables the create notification.
func (a *AccountAPI[T]) WithNoCreateNotification() *AccountAPI[T] {
a.nconfig.WithNoCreateNotification()
a.Logger.Info("Object creation notificaton disabled")
return a
}
// WithNoUpdateNotification disables the update notification.
func (a *AccountAPI[T]) WithNoUpdateNotification() *AccountAPI[T] {
a.nconfig.WithNoUpdateNotification()
a.Logger.Info("Object update notificaton disabled")
return a
}
// WithNoDeleteNotification disables the delete notification.
func (a *AccountAPI[T]) WithNoDeleteNotification() *AccountAPI[T] {
a.nconfig.WithNoDeleteNotification()
a.Logger.Info("Object deletion notificaton disabled")
return a
}
func (a *AccountAPI[T]) WithNoCreate() *AccountAPI[T] {
a.config.WithNoCreate()
a.Logger.Info("Create handler disabled")
return a
}
func (a *AccountAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithCreateHandler(handler)
a.Logger.Info("Create handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoList() *AccountAPI[T] {
a.config.WithNoList()
a.Logger.Info("List handler disabled")
return a
}
func (a *AccountAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithListHandler(handler)
a.Logger.Info("List handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoGet() *AccountAPI[T] {
a.config.WithNoGet()
a.Logger.Info("Get handler disabled")
return a
}
func (a *AccountAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithGetHandler(handler)
a.Logger.Info("Get handler overridden")
return a
}
func (a *AccountAPI[T]) WithReorderHandler(reorder ReorderConfig) *AccountAPI[T] {
a.config.WithReorderHandler(reorder)
a.Logger.Info("Reorder handler installed")
return a
}
func (a *AccountAPI[T]) WithNoUpdate() *AccountAPI[T] {
a.config.WithNoUpdate()
a.Logger.Info("Update handler disabled")
return a
}
func (a *AccountAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithUpdateHandler(handler)
a.Logger.Info("Update handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoDelete() *AccountAPI[T] {
a.config.WithNoDelete()
a.Logger.Info("Delete handler disabled")
return a
}
func (a *AccountAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithDeleteHandler(handler)
a.Logger.Info("Delete handler overriden")
return a
}
func CreateAPI[T any](a eapi.API, dbFactory func() (DB[T], error), resource mservice.Type) (*AccountAPI[T], error) {
p := &AccountAPI[T]{
Logger: a.Logger().Named(resource),
Oph: mutil.CreatePH("obj"), // to avoid collision with object_ref
Orgph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref
a: a,
config: NewConfig(),
resource: resource,
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
}
var err error
if p.DB, err = dbFactory(); err != nil {
p.Logger.Error("Failed to create protected database", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,31 @@
package aapitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI[T]) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var object T
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
a.Logger.Warn("Failed to decode object when updating settings", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
if err := a.DB.Update(r.Context(), *account.GetID(), &object); err != nil {
a.Logger.Warn("Error updating object", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.nconfig.UpdateNotification(&object, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send creation notification", zap.Error(err))
}
return a.Object(&object, accessToken)
}

View File

@@ -0,0 +1,95 @@
package accountapiimp
import (
"context"
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/account"
an "github.com/tech/sendico/pkg/messaging/notifications/account"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI) attemptDecodeAccount(r *http.Request) (*model.Account, error) {
var u model.Account
return &u, json.NewDecoder(r.Body).Decode(&u)
}
func (a *AccountAPI) reportUnauthorized(hint string) http.HandlerFunc {
return response.Unauthorized(a.logger, a.Name(), hint)
}
func (a *AccountAPI) reportDuplicateEmail() http.HandlerFunc {
return response.Forbidden(a.logger, a.Name(), "duplicate_email", "email has already been registered")
}
func (a *AccountAPI) reportEmailMissing() http.HandlerFunc {
return response.BadRequest(a.logger, a.Name(), "email_missing", "email is required")
}
func (a *AccountAPI) sendPasswordResetEmail(account *model.Account, resetToken string) error {
if err := a.producer.SendMessage(an.PasswordResetRequested(a.Name(), *account.GetID(), resetToken)); err != nil {
a.logger.Warn("Failed to send password reset notification", zap.Error(err))
return err
}
return nil
}
func (a *AccountAPI) getProfile(_ *http.Request, u *model.Account, token *sresponse.TokenData) http.HandlerFunc {
return sresponse.Account(a.logger, u, token)
}
func (a *AccountAPI) sendWelcomeEmail(account *model.Account, token string) error {
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID(), token)); err != nil {
a.logger.Warn("Failed to send account creation notification", zap.Error(err))
return err
}
return nil
}
func (a *AccountAPI) sendVerificationMail(r *http.Request, paramGetter func(ctx context.Context, db account.DB, user *model.Account) (*model.Account, error)) http.HandlerFunc {
// Validate user input
u, err := a.attemptDecodeAccount(r)
if err != nil {
a.logger.Warn("Failed to decide profile update", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
ctx := r.Context()
accnt, err := paramGetter(ctx, a.db, u)
if err != nil || accnt == nil {
a.logger.Warn("Failed to ger user from db with", zap.Error(err), mzap.StorableRef(u))
return response.Internal(a.logger, a.Name(), err)
}
token, err := a.accService.VerifyAccount(ctx, accnt)
if err != nil {
a.logger.Warn("Failed to create verification token for account", zap.Error(err), mzap.StorableRef(accnt))
return response.Internal(a.logger, a.Name(), err)
}
// Send welcome email
if err = a.sendWelcomeEmail(accnt, token); err != nil {
a.logger.Warn("Failed to send verification email",
zap.Error(err), mzap.StorableRef(accnt), zap.String("email", accnt.Login))
return response.Internal(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}
func getID(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
var res model.Account
return &res, db.Get(ctx, *u.GetID(), &res)
}
func getEmail(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
return db.GetByEmail(ctx, u.Login)
}
func (a *AccountAPI) reportNoEmailRegistered() http.HandlerFunc {
return response.BadRequest(a.logger, a.Name(), "email_not_registered", "no account registered with this email")
}

View File

@@ -0,0 +1,130 @@
package accountapiimp
import (
"context"
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI) deleteProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
// Get the current organization from the request context
orgRef, err := a.getCurrentOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
}
// Get the organization
var org model.Organization
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
// Delete the account (this will check if it's the only member)
if err := a.accService.DeleteAccount(ctx, &org, account.ID); err != nil {
if errors.Is(err, merrors.ErrInvalidArg) {
a.logger.Warn("Cannot delete account - validation failed", zap.Error(err), mzap.StorableRef(account))
return response.BadRequest(a.logger, a.Name(), "validation_failed", err.Error())
}
a.logger.Error("Failed to delete account", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("Account deleted successfully", mzap.StorableRef(account))
return response.Success(a.logger)
}
func (a *AccountAPI) deleteOrganization(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
// Get the current organization from the request context
orgRef, err := a.getCurrentOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
}
// Get the organization
var org model.Organization
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
// Delete the organization and all its data
if err := a.accService.DeleteOrganization(ctx, &org); err != nil {
a.logger.Error("Failed to delete organization", zap.Error(err), mzap.StorableRef(&org))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("Organization deleted successfully", mzap.StorableRef(&org))
return response.Success(a.logger)
}
func (a *AccountAPI) deleteAll(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
// Get the current organization from the request context
orgRef, err := a.getCurrentOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
}
// Get the organization
var org model.Organization
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
a.logger.Error("Failed to fetch organization", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
// Get organization permission reference
var orgPolicy model.PolicyDescription
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
a.logger.Error("Failed to fetch organization policy", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
// Check if user has permission to delete the organization
canDelete, err := a.enf.Enforce(ctx, orgPolicy.ID, account.ID, orgRef, bson.NilObjectID, model.ActionDelete)
if err != nil {
a.logger.Error("Failed to check delete permission", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !canDelete {
a.logger.Warn("User does not have permission to delete organization", mzap.StorableRef(account), mzap.StorableRef(&org))
return response.AccessDenied(a.logger, a.Name(), "Insufficient permissions to delete organization")
}
// Delete everything (organization + account)
if _, err := a.tf.CreateTransaction().Execute(ctx, func(c context.Context) (any, error) {
if err := a.accService.DeleteAll(c, &org, account.ID); err != nil {
a.logger.Warn("Failed to delete all data", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(account))
return nil, err
}
return nil, nil
}); err != nil {
a.logger.Warn("Failed to execute delete transaction", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("All data deleted successfully", mzap.StorableRef(&org), mzap.StorableRef(account))
return response.Success(a.logger)
}
// Helper method to get current organization reference from request context
func (a *AccountAPI) getCurrentOrganizationRef(r *http.Request) (bson.ObjectID, error) {
return a.oph.GetRef(r)
}

View File

@@ -0,0 +1,49 @@
package accountapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI) dzone(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
orgs, err := a.odb.List(ctx, account.ID, nil)
if err != nil {
a.logger.Error("Failed to list owned organizations", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
orgsPBS := make([]model.PermissionBoundStorable, len(orgs))
for i, org := range orgs {
orgsPBS[i] = &org
}
res, err := a.enf.EnforceBatch(ctx, orgsPBS, account.ID, model.ActionDelete)
if err != nil {
a.logger.Error("Failed to enforce permissions", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
validOrgs := make([]model.Organization, 0, len(orgs))
for _, org := range orgs {
if res[org.ID] {
validOrgs = append(validOrgs, org)
a.logger.Debug("Organization can be deleted", mzap.StorableRef(&org), mzap.StorableRef(account))
} else {
a.logger.Debug("Organization does not have delete permission for account", mzap.StorableRef(&org), mzap.StorableRef(account))
}
}
return sresponse.DZone(
a.logger,
&model.DZone{
CanDeleteAccount: true,
CanDeleteCascade: len(validOrgs) > 0,
Organizations: validOrgs,
},
token,
)
}

View File

@@ -0,0 +1,61 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI) verify(r *http.Request) http.HandlerFunc {
// Validate user input
token := mutil.GetToken(r)
// Get user
ctx := r.Context()
// Delete verification token to confirm account
t, err := a.vdb.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, token)
if err != nil {
a.logger.Debug("Failed to consume verification token", zap.Error(err))
return a.mapTokenErrorToResponse(err)
}
if t.Purpose != model.PurposeAccountActivation {
a.logger.Warn("Invalid token purpose", zap.String("expected", string(model.PurposeAccountActivation)), zap.String("actual", string(t.Purpose)))
return response.DataConflict(a.logger, a.Name(), "Invalid token purpose")
}
var user model.Account
if err := a.db.Get(ctx, t.AccountRef, &user); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Verified user not found", zap.Error(err))
return response.NotFound(a.logger, a.Name(), "User not found")
}
a.logger.Warn("Failed to fetch account", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
user.Status = model.AccountActive
if err = a.db.Update(ctx, &user); err != nil {
a.logger.Warn("Failed to save account while verifying account", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.sendAccountVerificationCompletedNotification(&user); err != nil {
a.logger.Warn("Failed to enqueue account verification notification", zap.Error(err), zap.String("email", user.Login))
}
// TODO: Send verification confirmation email
return response.Success(a.logger)
}
func (a *AccountAPI) resendVerificationMail(r *http.Request) http.HandlerFunc {
return a.sendVerificationMail(r, getID)
}
func (a *AccountAPI) resendVerification(r *http.Request) http.HandlerFunc {
return a.sendVerificationMail(r, getEmail)
}

View File

@@ -0,0 +1,43 @@
package accountapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI) getEmployees(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to fetch organizaiton reference", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check accounts access permissions", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading organization employees", mzap.StorableRef(account))
return response.AccessDenied(a.logger, a.Name(), "orgnizations employees read permission denied")
}
var org model.Organization
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
a.logger.Warn("Failed to fetch organization", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
emps, err := a.db.GetAccountsByRefs(ctx, orgRef, org.Members)
if err != nil {
a.logger.Warn("Failed to fetch organization emplpyees", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.Accounts(a.logger, emps, orgRef, token)
}

View File

@@ -0,0 +1,83 @@
package accountapiimp
import (
"encoding/json"
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI) updateEmployee(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.getCurrentOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to get current organization", zap.Error(err), mzap.StorableRef(account))
return response.BadRequest(a.logger, a.Name(), "invalid_organization_context", "Invalid organization context")
}
// Validate user input
var u model.AccountPublic
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
a.logger.Warn("Failed to decide profile update", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.accountsPermissionRef, account.ID, orgRef, u.ID, model.ActionUpdate)
if err != nil {
a.logger.Warn("Failed to check employee update permission", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Permission deined for employee update", mzap.StorableRef(account), mzap.ObjRef("employee_ref", u.ID))
return response.Auto(a.logger, a.Name(), merrors.AccessDenied(mservice.Accounts, string(model.ActionUpdate), u.ID))
}
if u.Login == "" {
a.logger.Debug("No email in request")
return a.reportEmailMissing()
}
if u.Name == "" {
a.logger.Debug("No name in request")
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
}
var acc model.Account
if err := a.db.Get(ctx, u.ID, &acc); err != nil {
a.logger.Warn("Failed to fetch employee account", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
return response.Auto(a.logger, a.Name(), err)
}
if acc.Login != u.Login {
// Change email address
verificationToken, err := a.accService.UpdateLogin(ctx, &acc, u.Login)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
return a.reportDuplicateEmail()
}
a.logger.Warn("Error while updating login", zap.Error(err), mzap.ObjRef("employee_ref", u.ID))
return response.Internal(a.logger, a.Name(), err)
}
// Send verification email
if err = a.sendWelcomeEmail(&acc, verificationToken); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(&acc))
return response.Internal(a.logger, a.Name(), err)
}
} else {
// Save the user
acc.AccountPublic = u
if err = a.db.Update(ctx, &acc); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(&acc))
return response.Internal(a.logger, a.Name(), err)
}
}
return sresponse.Account(a.logger, &acc, token)
}

View File

@@ -0,0 +1,196 @@
package accountapiimp
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *AccountAPI) checkPassword(_ *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.Account(a.logger, account, accessToken)
}
func (a *AccountAPI) changePassword(r *http.Request, user *model.Account, token *sresponse.TokenData) http.HandlerFunc {
// TODO: add rate check
var pcr srequest.ChangePassword
if err := json.NewDecoder(r.Body).Decode(&pcr); err != nil {
a.logger.Warn("Failed to decode password change request", zap.Error(err), mzap.StorableRef(user))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.accService.ValidatePassword(pcr.New, &pcr.Old); err != nil {
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(user))
return sresponse.BadRPassword(a.logger, a.Name(), err)
}
ctx := r.Context()
if !user.MatchPassword(pcr.Old) {
a.logger.Info("Old password does not match", mzap.StorableRef(user))
return a.reportUnauthorized("old password does not match")
}
user.Password = pcr.New
if err := user.HashPassword(); err != nil {
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.db.Update(ctx, user); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(user))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.rtdb.RevokeAll(ctx, *user.GetID(), pcr.DeviceID); err != nil {
a.logger.Warn("Failed to revoke refresh tokens",
zap.Error(err), mzap.StorableRef(user), zap.String("device_id", pcr.DeviceID))
}
return sresponse.Account(a.logger, user, token)
}
func (a *AccountAPI) forgotPassword(r *http.Request) http.HandlerFunc {
var req srequest.ForgotPassword
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode password change request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if req.Login == "" {
a.logger.Debug("Email is missing in the request")
return a.reportEmailMissing()
}
// Always use the lower case email address
req.Login = strings.ToLower(req.Login)
// Get user
ctx := r.Context()
user, err := a.db.GetByEmail(ctx, req.Login)
if (errors.Is(err, merrors.ErrNoData)) || (user == nil) {
a.logger.Debug("User not found while recovering password", zap.Error(err), zap.String("email", req.Login))
return a.reportNoEmailRegistered()
}
if err != nil {
a.logger.Warn("Failed to fetch user", zap.Error(err), zap.String("email", req.Login))
return response.Auto(a.logger, a.Name(), err)
}
// Generate reset password token
verificationToken, err := a.accService.ResetPassword(ctx, user)
if err != nil {
a.logger.Warn("Failed to generate reset password token", zap.Error(err), mzap.StorableRef(user))
return response.Auto(a.logger, a.Name(), err)
}
// Send reset password email
if err = a.sendPasswordResetEmail(user, verificationToken); err != nil {
a.logger.Warn("Failed to send reset password email", zap.Error(err), mzap.StorableRef(user))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("Reset password email sent successfully", zap.String("email", user.Login))
return response.Success(a.logger)
}
func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
ctx := r.Context()
// Get account reference and token from URL parameters using parameter helpers
accountRef, err := a.aph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to get account reference", zap.Error(err), mutil.PLog(a.aph, r))
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), err)
}
token := a.tph.GetID(r)
if token == "" {
a.logger.Warn("Missing token in reset password request")
return response.BadRequest(a.logger, a.Name(), "missing_parameters", "token is required")
}
// Get user from database
var user model.Account
err = a.db.Get(ctx, accountRef, &user)
if errors.Is(err, merrors.ErrNoData) {
a.logger.Info("User not found for password reset", zap.String("account_ref", accountRef.Hex()))
return response.NotFound(a.logger, a.Name(), "User not found")
}
if err != nil {
a.logger.Warn("Failed to get user for password reset", zap.Error(err), zap.String("account_ref", accountRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}
t, err := a.vdb.Consume(ctx, accountRef, model.PurposePasswordReset, token)
if err != nil {
a.logger.Warn("Failed to consume password reset token", zap.Error(err), zap.String("token", token))
return a.mapTokenErrorToResponse(err)
}
if t.Purpose != model.PurposePasswordReset {
a.logger.Warn("Invalid token purpose for password reset", zap.String("expected", string(model.PurposePasswordReset)), zap.String("actual", string(t.Purpose)))
return response.DataConflict(a.logger, a.Name(), "Invalid token purpose")
}
if t.AccountRef != accountRef {
a.logger.Warn("Token account reference does not match request account reference", zap.String("token_account_ref", t.AccountRef.Hex()), zap.String("request_account_ref", accountRef.Hex()))
return response.DataConflict(a.logger, a.Name(), "Token does not match account")
}
// Parse new password from request body
var req srequest.ResetPassword
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode reset password request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
// Validate new password
if err := a.accService.ValidatePassword(req.Password, nil); err != nil {
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(&user))
return sresponse.BadRPassword(a.logger, a.Name(), err)
}
// Execute password reset in transaction to ensure atomicity
if _, err := a.tf.CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
return a.resetPasswordTransactionBody(ctx, &user, req.Password)
}); err != nil {
a.logger.Warn("Failed to execute password reset transaction", zap.Error(err), mzap.StorableRef(&user))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Info("Password reset successful", mzap.StorableRef(&user))
return response.Success(a.logger)
}
// resetPasswordTransactionBody contains the transaction logic for password reset
func (a *AccountAPI) resetPasswordTransactionBody(ctx context.Context, user *model.Account, newPassword string) (any, error) {
// Update user with new password and clear reset token
user.Password = newPassword
// Hash the new password
if err := user.HashPassword(); err != nil {
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
return nil, err
}
// Save the updated user
if err := a.db.Update(ctx, user); err != nil {
a.logger.Warn("Failed to save user with new password", zap.Error(err), mzap.StorableRef(user))
return nil, err
}
// Revoke all refresh tokens for this user (force re-login)
if err := a.rtdb.RevokeAll(ctx, user.ID, ""); err != nil {
a.logger.Warn("Failed to revoke refresh tokens after password reset", zap.Error(err), mzap.StorableRef(user))
// Don't fail the transaction if token revocation fails, but log it
}
return nil, nil
}

View File

@@ -0,0 +1,278 @@
package accountapiimp
import (
"testing"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/v2/bson"
)
// TestPasswordResetFlowLogic tests the logical flow without database dependencies
func TestPasswordResetFlowLogic(t *testing.T) {
t.Run("CompleteFlow", func(t *testing.T) {
// Step 1: User requests password reset
userEmail := "test@example.com"
assert.NotEmpty(t, userEmail, "Email should not be empty")
assert.Contains(t, userEmail, "@", "Email should contain @ symbol")
// Step 2: System generates reset token
originalToken := ""
resetToken := "generated-reset-token-123"
assert.NotEmpty(t, resetToken, "Reset token should be generated")
assert.NotEqual(t, originalToken, resetToken, "Reset token should be different from empty")
// Step 3: User clicks reset link with token
userID := bson.NewObjectID()
assert.NotEqual(t, bson.NilObjectID, userID, "User ID should be valid")
// Step 4: System validates token and updates password
storedToken := resetToken
providedToken := resetToken
tokenValid := storedToken == providedToken
assert.True(t, tokenValid, "Token should be valid")
// Step 5: Password gets updated and token cleared
oldPassword := "old-password"
newPassword := "new-password-123!"
clearedToken := ""
assert.NotEqual(t, oldPassword, newPassword, "Password should be changed")
assert.Empty(t, clearedToken, "Token should be cleared after use")
assert.NotEqual(t, storedToken, clearedToken, "Token should be different after clearing")
})
t.Run("TokenSecurity", func(t *testing.T) {
// Test that tokens are single-use
originalToken := "valid-token-123"
usedToken := "" // After use, token should be cleared
assert.NotEmpty(t, originalToken, "Original token should exist")
assert.Empty(t, usedToken, "Used token should be cleared")
assert.NotEqual(t, originalToken, usedToken, "Token should be cleared after use")
// Test that different tokens are not equal
token1 := "token-123"
token2 := "token-456"
assert.NotEqual(t, token1, token2, "Different tokens should not be equal")
})
}
// TestPasswordValidationLogic tests password complexity requirements
func TestPasswordValidationLogic(t *testing.T) {
t.Run("ValidPasswords", func(t *testing.T) {
validPasswords := []string{
"Password123!",
"MySecurePass1@",
"ComplexP@ssw0rd",
}
for _, password := range validPasswords {
t.Run(password, func(t *testing.T) {
// Test minimum length
assert.True(t, len(password) >= 8, "Password should be at least 8 characters")
// Test for at least one digit
hasDigit := false
for _, char := range password {
if char >= '0' && char <= '9' {
hasDigit = true
break
}
}
assert.True(t, hasDigit, "Password should contain at least one digit")
// Test for at least one uppercase letter
hasUpper := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
break
}
}
assert.True(t, hasUpper, "Password should contain at least one uppercase letter")
// Test for at least one lowercase letter
hasLower := false
for _, char := range password {
if char >= 'a' && char <= 'z' {
hasLower = true
break
}
}
assert.True(t, hasLower, "Password should contain at least one lowercase letter")
// Test for at least one special character
hasSpecial := false
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
for _, char := range password {
for _, special := range specialChars {
if char == special {
hasSpecial = true
break
}
}
if hasSpecial {
break
}
}
assert.True(t, hasSpecial, "Password should contain at least one special character")
})
}
})
t.Run("InvalidPasswords", func(t *testing.T) {
invalidPasswords := []string{
"", // Empty
"short", // Too short
"nouppercase1!", // No uppercase
"NOLOWERCASE1!", // No lowercase
"NoNumbers!", // No numbers
"NoSpecial1", // No special characters
}
for _, password := range invalidPasswords {
t.Run(password, func(t *testing.T) {
// Test that invalid passwords fail at least one requirement
isValid := true
// Check length
if len(password) < 8 {
isValid = false
}
// Check for digit
hasDigit := false
for _, char := range password {
if char >= '0' && char <= '9' {
hasDigit = true
break
}
}
if !hasDigit {
isValid = false
}
// Check for uppercase
hasUpper := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
break
}
}
if !hasUpper {
isValid = false
}
// Check for lowercase
hasLower := false
for _, char := range password {
if char >= 'a' && char <= 'z' {
hasLower = true
break
}
}
if !hasLower {
isValid = false
}
// Check for special character
hasSpecial := false
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
for _, char := range password {
for _, special := range specialChars {
if char == special {
hasSpecial = true
break
}
}
if hasSpecial {
break
}
}
if !hasSpecial {
isValid = false
}
assert.False(t, isValid, "Invalid password should fail validation")
})
}
})
}
// TestEmailValidationLogic tests email format validation
func TestEmailValidationLogic(t *testing.T) {
t.Run("ValidEmails", func(t *testing.T) {
validEmails := []string{
"test@example.com",
"user.name@domain.org",
"user+tag@example.co.uk",
"test123@domain.com",
}
for _, email := range validEmails {
t.Run(email, func(t *testing.T) {
// Basic email validation logic
hasAt := false
hasDot := false
atIndex := -1
dotIndex := -1
for i, char := range email {
if char == '@' {
hasAt = true
atIndex = i
}
if char == '.' {
hasDot = true
dotIndex = i
}
}
assert.True(t, hasAt, "Valid email should contain @")
assert.True(t, hasDot, "Valid email should contain .")
assert.True(t, atIndex > 0, "Valid email should have @ not at start")
assert.True(t, dotIndex > atIndex, "Valid email should have . after @")
assert.True(t, len(email) > atIndex+1, "Valid email should have domain after @")
})
}
})
t.Run("InvalidEmails", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"noat.com", // No @
"test@nodot", // No .
"@nodomain.com", // No local part
"test@.com", // No domain
"test.com@", // No domain after @
}
for _, email := range invalidEmails {
t.Run(email, func(t *testing.T) {
// Basic email validation logic
hasAt := false
hasDot := false
atIndex := -1
dotIndex := -1
for i, char := range email {
if char == '@' {
hasAt = true
atIndex = i
}
if char == '.' {
hasDot = true
dotIndex = i
}
}
// Invalid emails should fail at least one requirement
domainAfterDot := len(email) > dotIndex+1
domainAfterAt := len(email) > atIndex+1
isValid := hasAt && hasDot && atIndex > 0 && dotIndex > atIndex && domainAfterAt && domainAfterDot && (dotIndex-atIndex) > 1
assert.False(t, isValid, "Invalid email should fail validation")
})
}
})
}

View File

@@ -0,0 +1,278 @@
package accountapiimp
import (
"context"
"fmt"
"os"
"strings"
"time"
trongatewayclient "github.com/tech/sendico/gateway/tron/client"
ledgerclient "github.com/tech/sendico/ledger/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/db/policy"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/db/verification"
"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/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/accountservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/fileservice"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type AccountAPI struct {
logger mlogger.Logger
db account.DB
odb organization.DB
tf transaction.Factory
rtdb refreshtokens.DB
plcdb policy.DB
vdb verification.DB
domain domainprovider.DomainProvider
avatars mservice.MicroService
producer messaging.Producer
pmanager auth.Manager
enf auth.Enforcer
oph mutil.ParamHelper
aph mutil.ParamHelper
tph mutil.ParamHelper
accountsPermissionRef bson.ObjectID
accService accountservice.AccountService
chainGateway chainWalletClient
ledgerClient ledgerAccountClient
chainAsset *chainv1.Asset
}
type chainWalletClient interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
Close() error
}
type ledgerAccountClient interface {
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
Close() error
}
func (a *AccountAPI) Name() mservice.Type {
return mservice.Accounts
}
func (a *AccountAPI) Finish(ctx context.Context) error {
if err := a.avatars.Finish(ctx); err != nil {
return err
}
if a.chainGateway != nil {
if err := a.chainGateway.Close(); err != nil {
a.logger.Warn("Failed to close chain gateway client", zap.Error(err))
}
}
if a.ledgerClient != nil {
if err := a.ledgerClient.Close(); err != nil {
a.logger.Warn("Failed to close ledger client", zap.Error(err))
}
}
return nil
}
func CreateAPI(a eapi.API) (*AccountAPI, error) {
p := new(AccountAPI)
p.logger = a.Logger().Named(p.Name())
var err error
if p.db, err = a.DBFactory().NewAccountDB(); err != nil {
p.logger.Error("Failed to create accounts database", zap.Error(err))
return nil, err
}
if p.rtdb, err = a.DBFactory().NewRefreshTokensDB(); err != nil {
p.logger.Error("Failed to create refresh tokens database", zap.Error(err))
return nil, err
}
if p.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
p.logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
if p.plcdb, err = a.DBFactory().NewPoliciesDB(); err != nil {
p.logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
if p.vdb, err = a.DBFactory().NewVerificationsDB(); err != nil {
p.logger.Error("Failed to create verification database", zap.Error(err))
return nil, err
}
p.domain = a.DomainProvider()
p.producer = a.Register().Messaging().Producer()
p.tf = a.DBFactory().TransactionFactory()
p.pmanager = a.Permissions().Manager()
p.enf = a.Permissions().Enforcer()
p.oph = mutil.CreatePH(mservice.Organizations)
p.aph = mutil.CreatePH(mservice.Accounts)
p.tph = mutil.CreatePH("token")
if p.accService, err = accountservice.NewAccountService(p.logger, a.DBFactory(), p.enf, p.pmanager.Role(), &a.Config().Mw.Password); err != nil {
p.logger.Error("Failed to create account manager", zap.Error(err))
return nil, err
}
// Account related api endpoints
a.Register().Handler(mservice.Accounts, "/signup", api.Post, p.signup)
a.Register().Handler(mservice.Accounts, "/signup/availability", api.Get, p.signupAvailability)
a.Register().AccountHandler(mservice.Accounts, "", api.Put, p.updateProfile)
a.Register().AccountHandler(mservice.Accounts, "", api.Get, p.getProfile)
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/employee"), api.Put, p.updateEmployee)
a.Register().AccountHandler(mservice.Accounts, "/dzone", api.Get, p.dzone)
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/profile"), api.Delete, p.deleteProfile)
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/organization"), api.Delete, p.deleteOrganization)
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/all"), api.Delete, p.deleteAll)
a.Register().AccountHandler(mservice.Accounts, p.oph.AddRef("/list"), api.Get, p.getEmployees)
a.Register().AccountHandler(mservice.Accounts, "/password", api.Post, p.checkPassword)
a.Register().AccountHandler(mservice.Accounts, "/password", api.Patch, p.changePassword)
a.Register().Handler(mservice.Accounts, "/password", api.Put, p.forgotPassword)
a.Register().Handler(mservice.Accounts, p.tph.AddRef(p.aph.AddRef("/password/reset")), api.Post, p.resetPassword)
a.Register().Handler(mservice.Accounts, mutil.AddToken("/verify"), api.Get, p.verify)
a.Register().Handler(mservice.Accounts, "/email", api.Post, p.resendVerificationMail)
a.Register().Handler(mservice.Accounts, "/email", api.Put, p.resendVerification)
if p.avatars, err = fileservice.CreateAPI(a, p.Name()); err != nil {
p.logger.Error("Failed to create image server", zap.Error(err))
return nil, err
}
accountsPolicy, err := a.Permissions().GetPolicyDescription(context.Background(), mservice.Accounts)
if err != nil {
p.logger.Warn("Failed to fetch account permission policy description", zap.Error(err))
return nil, err
}
p.accountsPermissionRef = accountsPolicy.ID
cfg := a.Config()
if cfg == nil {
p.logger.Error("Failed to fetch service configuration")
return nil, merrors.InvalidArgument("No configuration provided")
}
if err := p.initChainGateway(cfg.ChainGateway); err != nil {
p.logger.Error("Failed to initialize chain gateway client", zap.Error(err))
return nil, err
}
if err := p.initLedgerClient(cfg.Ledger); err != nil {
p.logger.Error("Failed to initialize ledger client", zap.Error(err))
return nil, err
}
return p, nil
}
func (a *AccountAPI) initChainGateway(cfg *eapi.ChainGatewayConfig) error {
if cfg == nil {
return merrors.InvalidArgument("chain gateway configuration is not provided")
}
cfg.Address = strings.TrimSpace(cfg.Address)
if cfg.Address == "" {
cfg.Address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
}
if cfg.Address == "" {
return merrors.InvalidArgument(fmt.Sprintf("chain gateway address is not specified and address env %s is empty", cfg.AddressEnv))
}
clientCfg := trongatewayclient.Config{
Address: cfg.Address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := trongatewayclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
asset, err := buildGatewayAsset(cfg.DefaultAsset)
if err != nil {
_ = client.Close()
return err
}
a.chainGateway = client
a.chainAsset = asset
return nil
}
func (a *AccountAPI) initLedgerClient(cfg *eapi.LedgerConfig) error {
if cfg == nil {
return merrors.InvalidArgument("ledger configuration is not provided")
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
}
if address == "" {
return merrors.InvalidArgument(fmt.Sprintf("ledger address is not specified and address env %s is empty", cfg.AddressEnv))
}
clientCfg := ledgerclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := ledgerclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
a.ledgerClient = client
return nil
}
func buildGatewayAsset(cfg eapi.ChainGatewayAssetConfig) (*chainv1.Asset, error) {
chain, err := parseChainNetwork(cfg.Chain)
if err != nil {
return nil, err
}
tokenSymbol := strings.TrimSpace(cfg.TokenSymbol)
if tokenSymbol == "" {
return nil, merrors.InvalidArgument("chain gateway token symbol is required")
}
return &chainv1.Asset{
Chain: chain,
TokenSymbol: strings.ToUpper(tokenSymbol),
ContractAddress: strings.TrimSpace(cfg.ContractAddress),
}, nil
}
func parseChainNetwork(value string) (chainv1.ChainNetwork, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "ETHEREUM_MAINNET", "CHAIN_NETWORK_ETHEREUM_MAINNET":
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case "ARBITRUM_ONE", "CHAIN_NETWORK_ARBITRUM_ONE":
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case "TRON_MAINNET", "CHAIN_NETWORK_TRON_MAINNET":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case "TRON_NILE", "CHAIN_NETWORK_TRON_NILE":
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
case "", "CHAIN_NETWORK_UNSPECIFIED":
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("chain network must be specified")
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", value))
}
}

View File

@@ -0,0 +1,19 @@
package accountapiimp
import (
"testing"
"github.com/stretchr/testify/require"
eapi "github.com/tech/sendico/server/interface/api"
)
func TestBuildGatewayAsset_PreservesContractAddressCase(t *testing.T) {
asset, err := buildGatewayAsset(eapi.ChainGatewayAssetConfig{
Chain: "TRON_MAINNET",
TokenSymbol: "usdt",
ContractAddress: "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T",
})
require.NoError(t, err)
require.Equal(t, "USDT", asset.GetTokenSymbol())
require.Equal(t, "TR7NhQjeKQxGTCi8q8ZY4pL8otSzgjLj6T", asset.GetContractAddress())
}

View File

@@ -0,0 +1,382 @@
package accountapiimp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/merrors"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef bson.ObjectID) (*model.Organization, error) {
name := strings.TrimSpace(sr.Organization.Name)
if name == "" {
return nil, merrors.InvalidArgument("organization name must not be empty")
}
if _, err := time.LoadLocation(sr.OrganizationTimeZone); err != nil {
return nil, merrors.DataConflict(fmt.Sprintf("invalid time zone '%s' provided, error %s", sr.OrganizationTimeZone, err.Error()))
}
// explicitly set org ref for permission related checks as unprotected template implementation
// is not aware of permisssions and won't set org
orgRef := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
PermissionBound: model.PermissionBound{
Base: storable.Base{
ID: orgRef,
CreatedAt: time.Now(),
},
PermissionRef: permissionRef,
OrganizationBoundBase: model.OrganizationBoundBase{
OrganizationRef: orgRef,
},
},
Describable: model.Describable{
Name: name,
Description: sr.Organization.Description,
},
TimeZone: sr.OrganizationTimeZone,
},
Members: []bson.ObjectID{},
}
if err := a.odb.Unprotected().Create(ctx, org); err != nil {
a.logger.Warn("Failed to create organization", zap.Error(err))
return nil, err
}
return org, nil
}
// signupHandler handles user sign up
func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
// Validate user input
var sr srequest.Signup
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode signup request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
if err := a.ensureLoginAvailable(r.Context(), sr.Account.Login); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
}
if errors.Is(err, merrors.ErrInvalidArg) {
return response.BadPayload(a.logger, a.Name(), err)
}
a.logger.Warn("Failed to validate login availability", zap.Error(err), zap.String("login", sr.Account.Login))
return response.Internal(a.logger, a.Name(), err)
}
newAccount := sr.Account.ToAccount()
if res := a.accService.ValidateAccount(newAccount); res != nil {
a.logger.Warn("Invalid signup account received", zap.Error(res), zap.String("account", newAccount.Login))
return response.BadPayload(a.logger, a.Name(), res)
}
verificationToken, err := a.executeSignupTransaction(r.Context(), &sr, newAccount)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Warn("Failed to register account", zap.Error(err), zap.String("login", newAccount.Login))
return response.DataConflict(a.logger, "user_already_registered", "User has already been registered")
}
a.logger.Warn("Failed to create new user", zap.Error(err), zap.String("login", newAccount.Login))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.sendWelcomeEmail(newAccount, verificationToken); err != nil {
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
}
if err := a.sendSignupNotification(newAccount, &sr); err != nil {
a.logger.Warn("Failed to enqueue signup notification", zap.Error(err), zap.String("login", newAccount.Login))
}
return sresponse.SignUp(a.logger, newAccount)
}
func (a *AccountAPI) sendSignupNotification(account *model.Account, request *srequest.Signup) error {
if account == nil || request == nil {
return merrors.InvalidArgument("signup notification payload is empty")
}
signupNotification := &model.ContactRequest{
Name: accountNotificationName(account),
Email: strings.TrimSpace(account.Login),
Company: strings.TrimSpace(request.Organization.Name),
Topic: model.ContactRequestTopicSignupCompleted,
}
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), signupNotification))
}
func (a *AccountAPI) sendAccountVerificationCompletedNotification(account *model.Account) error {
if account == nil {
return merrors.InvalidArgument("account verification notification payload is empty", "account")
}
notification := &model.ContactRequest{
Name: accountNotificationName(account),
Email: strings.TrimSpace(account.Login),
Topic: model.ContactRequestTopicAccountVerificationCompleted,
}
return a.producer.SendMessage(snotifications.ContactRequest(a.Name(), notification))
}
func accountNotificationName(account *model.Account) string {
if account == nil {
return ""
}
return strings.TrimSpace(strings.Join([]string{
strings.TrimSpace(account.Name),
strings.TrimSpace(account.LastName),
}, " "))
}
func (a *AccountAPI) signupAvailability(r *http.Request) http.HandlerFunc {
login := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("login")))
if login == "" {
return response.BadRequest(a.logger, a.Name(), "missing_login", "login query parameter is required")
}
err := a.ensureLoginAvailable(r.Context(), login)
switch {
case err == nil:
return sresponse.SignUpAvailability(a.logger, login, true)
case errors.Is(err, merrors.ErrDataConflict):
return sresponse.SignUpAvailability(a.logger, login, false)
case errors.Is(err, merrors.ErrInvalidArg):
return response.BadPayload(a.logger, a.Name(), err)
default:
a.logger.Warn("Failed to check login availability", zap.Error(err), zap.String("login", login))
return response.Internal(a.logger, a.Name(), err)
}
}
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) (string, error) {
res, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
return a.signupTransactionBody(ctx, sr, newAccount)
})
if token, ok := res.(string); token != "" || ok {
return token, err
}
return "", err
}
func (a *AccountAPI) signupTransactionBody(ctx context.Context, sr *srequest.Signup, newAccount *model.Account) (any, error) {
var orgPolicy model.PolicyDescription
if err := a.plcdb.GetBuiltInPolicy(ctx, mservice.Organizations, &orgPolicy); err != nil {
a.logger.Warn("Failed to fetch built-in organization policy", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
org, err := a.createOrg(ctx, sr, orgPolicy.ID)
if err != nil {
a.logger.Warn("Failed to create organization", zap.Error(err))
return nil, err
}
a.logger.Info("Organization created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.openOrgWallet(ctx, org, sr); err != nil {
return nil, err
}
a.logger.Info("Organization wallet created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.openOrgLedgerAccount(ctx, org, sr); err != nil {
return nil, err
}
a.logger.Info("Organization ledger account created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
roleDescription, err := a.pmanager.Role().Create(ctx, org.ID, &sr.OwnerRole)
if err != nil {
a.logger.Warn("Failed to create owner role", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
a.logger.Info("Organization owner role created", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
return nil, err
}
a.logger.Info("Organization owner role permissions granted", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
token, err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID)
if err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
a.logger.Info("Organization owner account registered", mzap.StorableRef(org), zap.String("account", sr.Account.Login))
return token, nil
}
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef bson.ObjectID, roleID bson.ObjectID, newAccount *model.Account) error {
om := a.pmanager.Permission()
policies, err := a.plcdb.All(ctx, organizationRef)
if err != nil {
a.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
required := map[mservice.Type]bool{
mservice.Organizations: false,
mservice.Accounts: false,
mservice.LedgerAccounts: false,
}
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
for _, policy := range policies {
if policy.ResourceTypes != nil {
for _, resource := range *policy.ResourceTypes {
if _, ok := required[resource]; ok {
required[resource] = true
}
}
}
for _, action := range actions {
a.logger.Debug("Adding permission", mzap.StorableRef(&policy), zap.String("action", string(action)),
mzap.ObjRef("role_ref", roleID), mzap.ObjRef("policy_ref", policy.ID), mzap.ObjRef("organization_ref", organizationRef))
policy := model.RolePolicy{
Policy: model.Policy{
OrganizationRef: organizationRef,
DescriptionRef: policy.ID,
ObjectRef: nil, // all objects are affected
Effect: model.ActionEffect{Action: action, Effect: model.EffectAllow},
},
RoleDescriptionRef: roleID,
}
if err := om.GrantToRole(ctx, &policy); err != nil {
a.logger.Warn("Failed to grant permission", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
}
}
if err := om.Save(); err != nil {
a.logger.Warn("Failed to save permissions", zap.Error(err), mzap.StorableRef(newAccount))
return err
}
for resource, granted := range required {
if !granted {
a.logger.Warn("Required policy description not found for signup permissions", zap.String("resource", string(resource)))
}
}
return nil
}
func (a *AccountAPI) ensureLoginAvailable(ctx context.Context, login string) error {
if strings.TrimSpace(login) == "" {
return merrors.InvalidArgument("login must not be empty")
}
if _, err := a.db.GetByEmail(ctx, login); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil
}
a.logger.Warn("Failed to lookup account by login", zap.Error(err), zap.String("login", login))
return err
}
return merrors.DataConflict("account already exists")
}
func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
if a.chainGateway == nil || a.chainAsset == nil {
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
return merrors.Internal("chain gateway client is not configured")
}
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(),
Describable: &describablev1.Describable{
Name: sr.CryptoWallet.Name,
Description: sr.CryptoWallet.Description,
},
Asset: a.chainAsset,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
}
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
return merrors.Internal("chain gateway returned empty wallet reference")
}
a.logger.Info("Managed wallet created for organization", mzap.StorableRef(org), zap.String("wallet_ref", resp.Wallet.WalletRef))
return nil
}
func (a *AccountAPI) openOrgLedgerAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
if a.ledgerClient == nil {
a.logger.Warn("Ledger client not configured, skipping ledger account creation", mzap.StorableRef(org))
return merrors.Internal("ledger client is not configured")
}
if a.chainAsset == nil {
return merrors.Internal("chain gateway default asset is not configured")
}
// TODO: remove hardcode
currency := "RUB"
var describable *describablev1.Describable
name := strings.TrimSpace(sr.LedgerWallet.Name)
var description *string
if sr.LedgerWallet.Description != nil {
trimmed := strings.TrimSpace(*sr.LedgerWallet.Description)
if trimmed != "" {
description = &trimmed
}
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
resp, err := a.ledgerClient.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: org.ID.Hex(),
AccountType: ledgerv1.AccountType_ACCOUNT_TYPE_ASSET,
Currency: currency,
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,
},
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account for organization", zap.Error(err), mzap.StorableRef(org))
return err
}
if resp == nil || resp.GetAccount() == nil || strings.TrimSpace(resp.GetAccount().GetLedgerAccountRef()) == "" {
return merrors.Internal("ledger returned empty account reference")
}
a.logger.Info("Ledger account created for organization", mzap.StorableRef(org), zap.String("ledger_account_ref", resp.GetAccount().GetLedgerAccountRef()))
return nil
}

View File

@@ -0,0 +1,209 @@
package accountapiimp_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// TestSignupRequestSerialization tests JSON marshaling/unmarshaling with real MongoDB
func TestSignupRequestSerialization(t *testing.T) {
if os.Getenv("RUN_DOCKER_TESTS") == "" {
t.Skip("skipping: docker-dependent integration test (set RUN_DOCKER_TESTS=1 to enable)")
}
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")
defer func() {
err := mongoContainer.Terminate(ctx)
require.NoError(t, err, "failed to terminate 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(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
defer func() {
err := client.Disconnect(ctx)
require.NoError(t, err, "failed to disconnect from MongoDB")
}()
db := client.Database("test_signup")
collection := db.Collection("signup_requests")
t.Run("StoreAndRetrieveSignupRequest", func(t *testing.T) {
signupRequest := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationTimeZone: "UTC",
OwnerRole: model.Describable{
Name: "Owner",
},
}
// Store in MongoDB
result, err := collection.InsertOne(ctx, signupRequest)
require.NoError(t, err)
assert.NotNil(t, result.InsertedID)
// Retrieve from MongoDB
var retrieved srequest.Signup
err = collection.FindOne(ctx, map[string]interface{}{"_id": result.InsertedID}).Decode(&retrieved)
require.NoError(t, err)
// Verify data integrity
assert.Equal(t, signupRequest.Account.Login, retrieved.Account.Login)
assert.Equal(t, signupRequest.Account.Name, retrieved.Account.Name)
assert.Equal(t, signupRequest.Organization.Name, retrieved.Organization.Name)
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
})
}
// TestSignupHTTPSerialization tests HTTP request/response serialization
func TestSignupHTTPSerialization(t *testing.T) {
signupRequest := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationTimeZone: "UTC",
OwnerRole: model.Describable{
Name: "Owner",
},
}
t.Run("ValidJSONRequest", func(t *testing.T) {
// Serialize to JSON
reqBody, err := json.Marshal(signupRequest)
require.NoError(t, err)
// Create HTTP request
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBuffer(reqBody))
req.Header.Set("Content-Type", "application/json")
// Parse the request body
var parsedRequest srequest.Signup
err = json.NewDecoder(req.Body).Decode(&parsedRequest)
require.NoError(t, err)
// Verify parsing
assert.Equal(t, signupRequest.Account.Login, parsedRequest.Account.Login)
assert.Equal(t, signupRequest.Account.Name, parsedRequest.Account.Name)
assert.Equal(t, signupRequest.Organization.Name, parsedRequest.Organization.Name)
})
t.Run("UnicodeCharacters", func(t *testing.T) {
unicodeRequest := signupRequest
unicodeRequest.Account.Name = "Test 用户 Üser"
unicodeRequest.Organization.Name = "测试 Organization"
// Serialize to JSON
reqBody, err := json.Marshal(unicodeRequest)
require.NoError(t, err)
// Parse back
var parsedRequest srequest.Signup
err = json.Unmarshal(reqBody, &parsedRequest)
require.NoError(t, err)
// Verify unicode characters are preserved
assert.Equal(t, "Test 用户 Üser", parsedRequest.Account.Name)
assert.Equal(t, "测试 Organization", parsedRequest.Organization.Name)
})
t.Run("InvalidJSONRequest", func(t *testing.T) {
invalidJSON := `{"account": {"login": "test@example.com", "password": "invalid json structure`
req := httptest.NewRequest(http.MethodPost, "/signup", bytes.NewBufferString(invalidJSON))
req.Header.Set("Content-Type", "application/json")
var parsedRequest srequest.Signup
err := json.NewDecoder(req.Body).Decode(&parsedRequest)
assert.Error(t, err, "Should fail to parse invalid JSON")
})
}
// TestAccountDataConversion tests conversion between request and model types
func TestAccountDataConversion(t *testing.T) {
accountData := model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
}
t.Run("ToAccount", func(t *testing.T) {
account := accountData.ToAccount()
assert.Equal(t, accountData.Login, account.Login)
assert.Equal(t, accountData.Password, account.Password)
assert.Equal(t, accountData.Name, account.Name)
// Verify the account has proper structure
assert.NotNil(t, account)
assert.IsType(t, &model.Account{}, account)
})
t.Run("PasswordHandling", func(t *testing.T) {
account := accountData.ToAccount()
// Original password should be preserved before validation
assert.Equal(t, "TestPassword123!", account.Password)
// Verify password is not empty
assert.NotEmpty(t, account.Password)
})
}

View File

@@ -0,0 +1,116 @@
package accountapiimp
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type stubLedgerAccountClient struct {
createReq *ledgerv1.CreateAccountRequest
createResp *ledgerv1.CreateAccountResponse
createErr error
}
func (s *stubLedgerAccountClient) CreateAccount(_ context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) {
s.createReq = req
return s.createResp, s.createErr
}
func (s *stubLedgerAccountClient) Close() error {
return nil
}
func TestOpenOrgLedgerAccount(t *testing.T) {
t.Run("creates operating ledger account", func(t *testing.T) {
desc := " Main org ledger account "
sr := &srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "owner@example.com",
},
},
},
LedgerWallet: model.Describable{
Name: " Primary Ledger ",
Description: &desc,
},
}
org := &model.Organization{}
org.SetID(bson.NewObjectID())
ledgerStub := &stubLedgerAccountClient{
createResp: &ledgerv1.CreateAccountResponse{
Account: &ledgerv1.LedgerAccount{LedgerAccountRef: bson.NewObjectID().Hex()},
},
}
api := &AccountAPI{
logger: zap.NewNop(),
ledgerClient: ledgerStub,
chainAsset: &chainv1.Asset{
TokenSymbol: " usdt ",
},
}
err := api.openOrgLedgerAccount(context.Background(), org, sr)
assert.NoError(t, err)
if assert.NotNil(t, ledgerStub.createReq) {
assert.Equal(t, org.ID.Hex(), ledgerStub.createReq.GetOrganizationRef())
assert.Equal(t, "RUB", ledgerStub.createReq.GetCurrency())
assert.Equal(t, ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, ledgerStub.createReq.GetAccountType())
assert.Equal(t, ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE, ledgerStub.createReq.GetStatus())
assert.Equal(t, ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerStub.createReq.GetRole())
assert.Equal(t, map[string]string{
"source": "signup",
"login": "owner@example.com",
}, ledgerStub.createReq.GetMetadata())
if assert.NotNil(t, ledgerStub.createReq.GetDescribable()) {
assert.Equal(t, "Primary Ledger", ledgerStub.createReq.GetDescribable().GetName())
if assert.NotNil(t, ledgerStub.createReq.GetDescribable().Description) {
assert.Equal(t, "Main org ledger account", ledgerStub.createReq.GetDescribable().GetDescription())
}
}
}
})
t.Run("fails when ledger client is missing", func(t *testing.T) {
api := &AccountAPI{
logger: zap.NewNop(),
chainAsset: &chainv1.Asset{
TokenSymbol: "USDT",
},
}
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInternal))
})
t.Run("fails when ledger response has empty reference", func(t *testing.T) {
ledgerStub := &stubLedgerAccountClient{
createResp: &ledgerv1.CreateAccountResponse{},
}
api := &AccountAPI{
logger: zap.NewNop(),
ledgerClient: ledgerStub,
chainAsset: &chainv1.Asset{
TokenSymbol: "USDT",
},
}
err := api.openOrgLedgerAccount(context.Background(), &model.Organization{}, &srequest.Signup{})
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInternal))
})
}

View File

@@ -0,0 +1,355 @@
package accountapiimp
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
// TestTimezoneValidation tests timezone validation logic separately
func TestTimezoneValidation(t *testing.T) {
t.Run("ValidTimezones", func(t *testing.T) {
validTimezones := []string{
"UTC",
"America/New_York",
"Europe/London",
"Asia/Tokyo",
"Australia/Sydney",
}
for _, tz := range validTimezones {
t.Run(tz, func(t *testing.T) {
_, err := time.LoadLocation(tz)
assert.NoError(t, err, "Timezone %s should be valid", tz)
})
}
})
t.Run("InvalidTimezones", func(t *testing.T) {
invalidTimezones := []string{
"Invalid/Timezone",
"Not/A/Timezone",
"BadTimezone",
"America/NotACity",
}
for _, tz := range invalidTimezones {
t.Run(tz, func(t *testing.T) {
_, err := time.LoadLocation(tz)
assert.Error(t, err, "Timezone %s should be invalid", tz)
})
}
})
}
// TestCreateValidSignupRequest tests the helper function for creating valid requests
func TestCreateValidSignupRequest(t *testing.T) {
request := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationTimeZone: "UTC",
OwnerRole: model.Describable{
Name: "Owner",
},
}
// Validate the request structure
assert.Equal(t, "test@example.com", request.Account.Login)
assert.Equal(t, "TestPassword123!", request.Account.Password)
assert.Equal(t, "Test User", request.Account.Name)
assert.Equal(t, "Test Organization", request.Organization.Name)
assert.Equal(t, "UTC", request.OrganizationTimeZone)
}
// TestSignupRequestValidation tests various signup request validation scenarios
func TestSignupRequestValidation(t *testing.T) {
t.Run("ValidRequest", func(t *testing.T) {
request := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
},
Organization: model.Describable{
Name: "Test Organization",
},
OrganizationTimeZone: "UTC",
}
// Basic validation - all required fields present
assert.NotEmpty(t, request.Account.Login)
assert.NotEmpty(t, request.Account.Password)
assert.NotEmpty(t, request.Account.Name)
assert.NotEmpty(t, request.Organization.Name)
assert.NotEmpty(t, request.OrganizationTimeZone)
})
t.Run("EmailFormats", func(t *testing.T) {
validEmails := []string{
"test@example.com",
"user.name@example.com",
"user+tag@example.org",
"test123@domain.co.uk",
}
for _, email := range validEmails {
t.Run(email, func(t *testing.T) {
request := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: email,
},
},
},
}
assert.Equal(t, email, request.Account.Login)
assert.Contains(t, email, "@")
assert.Contains(t, email, ".")
})
}
})
t.Run("PasswordComplexity", func(t *testing.T) {
passwordTests := []struct {
name string
password string
valid bool
}{
{"Strong", "TestPassword123!", true},
{"WithNumbers", "MyPass123!", true},
{"WithSymbols", "Complex@Pass1", true},
{"TooShort", "Test1!", false},
{"NoNumbers", "TestPassword!", false},
{"NoSymbols", "TestPassword123", false},
{"NoUppercase", "testpassword123!", false},
{"NoLowercase", "TESTPASSWORD123!", false},
}
for _, tt := range passwordTests {
t.Run(tt.name, func(t *testing.T) {
request := srequest.Signup{
Account: model.AccountData{
LoginData: model.LoginData{
Password: tt.password,
},
},
}
// Basic structure validation
assert.Equal(t, tt.password, request.Account.Password)
if tt.valid {
assert.True(t, len(tt.password) >= 8, "Password should be at least 8 characters")
} else {
// For invalid passwords, at least one condition should fail
hasDigit := false
hasUpper := false
hasLower := false
hasSpecial := false
for _, char := range tt.password {
switch {
case char >= '0' && char <= '9':
hasDigit = true
case char >= 'A' && char <= 'Z':
hasUpper = true
case char >= 'a' && char <= 'z':
hasLower = true
case char >= '!' && char <= '/' || char >= ':' && char <= '@':
hasSpecial = true
}
}
// At least one requirement should fail for invalid passwords
if len(tt.password) >= 8 {
assert.False(t, hasDigit && hasUpper && hasLower && hasSpecial,
"Password %s should fail at least one requirement", tt.password)
}
}
})
}
})
}
// TestAccountDataToAccount tests the ToAccount method
func TestAccountDataToAccount(t *testing.T) {
accountData := model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
Describable: model.Describable{
Name: "Test User",
},
}
account := accountData.ToAccount()
assert.Equal(t, accountData.Login, account.Login)
assert.Equal(t, accountData.Password, account.Password)
assert.Equal(t, accountData.Name, account.Name)
// Verify the account has proper structure
assert.NotNil(t, account)
assert.IsType(t, &model.Account{}, account)
}
// TestColorValidation tests that colors are properly formatted
func TestColorValidation(t *testing.T) {
validColors := []string{
"#FF0000", // Red
"#00FF00", // Green
"#0000FF", // Blue
"#FFFFFF", // White
"#000000", // Black
"#FF8000", // Orange
}
for _, color := range validColors {
t.Run(color, func(t *testing.T) {
colorPtr := stringPtr(color)
assert.NotNil(t, colorPtr)
assert.Equal(t, color, *colorPtr)
assert.True(t, len(color) == 7, "Color should be 7 characters long")
assert.True(t, color[0] == '#', "Color should start with #")
})
}
}
type stubAccountDB struct {
result *model.Account
err error
}
func (s *stubAccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) {
return s.result, s.err
}
func (s *stubAccountDB) GetByToken(ctx context.Context, email string) (*model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (s *stubAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) InsertMany(ctx context.Context, objects []*model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Get(ctx context.Context, objectRef bson.ObjectID, result *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Update(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) Delete(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) DeleteMany(ctx context.Context, query builder.Query) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
return merrors.NotImplemented("stub")
}
func (s *stubAccountDB) FindOne(ctx context.Context, query builder.Query, result *model.Account) error {
return merrors.NotImplemented("stub")
}
func TestEnsureLoginAvailable(t *testing.T) {
ctx := context.Background()
logger := zap.NewNop()
t.Run("available", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: merrors.ErrNoData,
},
}
assert.NoError(t, api.ensureLoginAvailable(ctx, "new@example.com"))
})
t.Run("taken", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
result: &model.Account{},
},
}
err := api.ensureLoginAvailable(ctx, "used@example.com")
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrDataConflict))
})
t.Run("invalid login", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: merrors.ErrNoData,
},
}
err := api.ensureLoginAvailable(ctx, " ")
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrInvalidArg))
})
t.Run("db error", func(t *testing.T) {
api := &AccountAPI{
logger: logger,
db: &stubAccountDB{
err: errors.New("boom"),
},
}
err := api.ensureLoginAvailable(ctx, "err@example.com")
assert.EqualError(t, err, "boom")
})
}

View File

@@ -0,0 +1,31 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/verification"
"go.uber.org/zap"
)
func (a *AccountAPI) mapTokenErrorToResponse(err error) http.HandlerFunc {
if errors.Is(err, verification.ErrTokenNotFound) {
a.logger.Debug("Verification token not found during consume", zap.Error(err))
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
}
if errors.Is(err, verification.ErrTokenExpired) {
a.logger.Debug("Verification token expired during consume", zap.Error(err))
return response.Gone(a.logger, a.Name(), "token_expired", "verification token has expired")
}
if errors.Is(err, verification.ErrTokenAlreadyUsed) {
a.logger.Debug("Verification token already used during consume", zap.Error(err))
return response.DataConflict(a.logger, a.Name(), "verification token has already been used")
}
if err != nil {
a.logger.Warn("Uenxpected error during token verification", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
a.logger.Debug("No token verification error found")
return response.Success(a.logger)
}

View File

@@ -0,0 +1,61 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI) updateProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
// Validate user input
u, err := a.attemptDecodeAccount(r)
if err != nil {
a.logger.Warn("Failed to decide profile update", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
if u.Login == "" {
a.logger.Debug("No email in request")
return a.reportEmailMissing()
}
if u.Name == "" {
a.logger.Debug("No name in request")
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
}
if account.Login != u.Login {
// Change email address
verificationToken, err := a.accService.UpdateLogin(ctx, account, u.Login)
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Debug("Duplicate login, denying change...", zap.Error(err), mzap.StorableRef(u))
return a.reportDuplicateEmail()
}
a.logger.Warn("Error while updating login", zap.Error(err), mzap.StorableRef(u))
return response.Internal(a.logger, a.Name(), err)
}
// Send verification email
if err = a.sendWelcomeEmail(account, verificationToken); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(account))
return response.Internal(a.logger, a.Name(), err)
}
} else {
// Save the user
u.Password = account.Password
u.Status = account.Status
if err = a.db.Update(ctx, u); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(u))
return response.Internal(a.logger, a.Name(), err)
}
}
return sresponse.Account(a.logger, u, token)
}

View File

@@ -0,0 +1,42 @@
package fileserviceimp
import (
"mime/multipart"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *API) getFile(r *http.Request) http.HandlerFunc {
fileName := mutil.GetObjRef(r)
return a.fileManager.Get(r.Context(), fileName)
}
func (a *API) closeFile(file multipart.File) {
if err := file.Close(); err != nil {
a.logger.Warn("Failed to close file", zap.Error(err))
}
}
func (a *API) uploadFile(r *http.Request, _ *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
fileName := mutil.GetObjRef(r)
file, _, err := r.FormFile(a.subDir)
if err != nil {
a.logger.Warn("Failed to read form request", zap.Error(err), zap.String("field_name", a.subDir))
return response.BadRequest(a.logger, a.Name(), a.subDir+"_broken", err.Error())
}
defer a.closeFile(file)
url, err := a.fileManager.Save(r.Context(), file, fileName)
if err != nil {
a.logger.Warn("Failed to store file", zap.Error(err), zap.String(mutil.ObjRefName(), fileName), zap.String("field_name", a.subDir))
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.FileUploaded(a.logger, url)
}

View File

@@ -0,0 +1,46 @@
package fileserviceimp
import (
"context"
"path"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"github.com/tech/sendico/server/internal/server/fileserviceimp/storage"
"go.uber.org/zap"
)
type API struct {
logger mlogger.Logger
fileManager storage.FileManager
subDir string
}
func (a *API) Name() mservice.Type {
return "storage"
}
func (a *API) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API, service mservice.Type) (*API, error) {
p := new(API)
p.logger = a.Logger().Named(p.Name())
p.subDir = "image"
var err error
if p.fileManager, err = storage.Create(p.logger, a, service, service, p.subDir); err != nil {
p.logger.Warn("Failed to create storage manager", zap.String("directory", service), zap.Error(err))
return nil, err
}
p.logger.Info("Storage connected", zap.String("driver", string(a.Config().Storage.Driver)))
handler := path.Join(p.subDir, mutil.AddObjRef("/"))
a.Register().Handler(service, handler, api.Get, p.getFile)
a.Register().AccountHandler(service, handler, api.Post, p.uploadFile)
return p, nil
}

View File

@@ -0,0 +1,136 @@
package storage
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
storageconfig "github.com/tech/sendico/server/internal/server/fileserviceimp/storage/config"
"go.uber.org/zap"
)
type AWSS3Storage struct {
logger mlogger.Logger
s3Client *s3.Client
bucketName string
directory string
service mservice.Type
}
func (storage *AWSS3Storage) Delete(ctx context.Context, objID string) error {
fullPath := filepath.Join(storage.directory, objID)
_, err := storage.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(storage.bucketName),
Key: aws.String(fullPath),
})
if err != nil {
storage.logger.Warn("Failed to delete file from AWS S3", zap.Error(err), zap.String("obj_ref", objID))
return err
}
// Wait for object to be deleted
waiter := s3.NewObjectNotExistsWaiter(storage.s3Client)
err = waiter.Wait(ctx, &s3.HeadObjectInput{
Bucket: aws.String(storage.bucketName),
Key: aws.String(fullPath),
}, 30) // 30 second timeout
if err != nil {
storage.logger.Warn("Error occurred while waiting for S3 file deletion", zap.Error(err), zap.String("obj_ref", objID))
return err
}
return nil
}
func (storage *AWSS3Storage) s3URL(fullPath string) string {
return fmt.Sprintf("https://%s.s3.amazonaws.com/%s", storage.bucketName, fullPath)
}
func (storage *AWSS3Storage) Save(ctx context.Context, file io.Reader, objID string) (string, error) {
fullPath := filepath.Join(storage.directory, objID)
buf := new(bytes.Buffer)
_, err := io.Copy(buf, file)
if err != nil {
storage.logger.Warn("Failed to read file content", zap.Error(err), zap.String("obj_ref", objID))
return "", err
}
_, err = storage.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(storage.bucketName),
Key: aws.String(fullPath),
Body: bytes.NewReader(buf.Bytes()),
})
if err != nil {
storage.logger.Warn("Failed to upload file to S3", zap.Error(err), zap.String("obj_ref", objID))
return "", err
}
s3URL := storage.s3URL(fullPath)
storage.logger.Info("File upload complete", zap.String("obj_ref", objID), zap.String("s3_url", s3URL))
return s3URL, nil
}
func (storage *AWSS3Storage) Get(ctx context.Context, objID string) http.HandlerFunc {
storage.logger.Warn("Indirect access to the object should be avoided", zap.String("obj_ref", objID))
fullPath := filepath.Join(storage.directory, objID)
_, err := storage.s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(storage.bucketName),
Key: aws.String(fullPath),
})
if err != nil {
storage.logger.Warn("Failed to get file from S3", zap.Error(err), zap.String("obj_ref", objID))
return response.NotFound(storage.logger, storage.service, err.Error())
}
res := func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, storage.s3URL(fullPath), http.StatusFound)
}
return res
}
func CreateAWSS3Storage(logger mlogger.Logger, service mservice.Type, directory string, cfg storageconfig.AWSS3SConfig) (*AWSS3Storage, error) {
region := os.Getenv(cfg.RegionEnv)
accessKeyID := os.Getenv(cfg.AccessKeyIDEnv)
secretAccessKey := os.Getenv(cfg.SecretAccessKeyEnv)
bucketName := os.Getenv(cfg.BucketNameEnv)
// Create AWS config with static credentials
awsConfig, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
accessKeyID,
secretAccessKey,
"",
)),
)
if err != nil {
logger.Warn("Failed to create AWS config", zap.Error(err), zap.String("bucket", bucketName),
zap.String("access_key_id", accessKeyID), zap.String("region", region))
return nil, err
}
// Create S3 client
s3Client := s3.NewFromConfig(awsConfig)
res := &AWSS3Storage{
logger: logger.Named("aws_s3").Named(directory),
s3Client: s3Client,
bucketName: bucketName,
directory: directory,
service: service,
}
res.logger.Info("Storage installed", zap.String("bucket", bucketName), zap.String("region", region),
zap.String("access_key_id", accessKeyID))
return res, nil
}

View File

@@ -0,0 +1,8 @@
package config
type AWSS3SConfig struct {
AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"`
SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"`
RegionEnv string `mapstructure:"region_env" yaml:"region_env"`
BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"`
}

View File

@@ -0,0 +1,5 @@
package config
type LocalFSSConfig struct {
RootPath string `mapstructure:"root_path" yaml:"root_path"`
}

View File

@@ -0,0 +1,29 @@
package storage
import (
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
fsc "github.com/tech/sendico/server/interface/services/fileservice/config"
"github.com/tech/sendico/server/internal/server/fileserviceimp/storage/config"
)
func Create(logger mlogger.Logger, a api.API, service mservice.Type, directory, subDir string) (FileManager, error) {
if a.Config().Storage.Driver == fsc.LocalFS {
var conf config.LocalFSSConfig
if err := mapstructure.Decode(a.Config().Storage.Settings, &conf); err != nil {
return nil, err
}
return CreateLocalFileStorage(logger, service, directory, subDir, a.DomainProvider(), conf)
}
if a.Config().Storage.Driver == fsc.AwsS3 {
var conf config.AWSS3SConfig
if err := mapstructure.Decode(a.Config().Storage.Settings, &conf); err != nil {
return nil, err
}
return CreateAWSS3Storage(logger, service, directory, conf)
}
return nil, merrors.Internal("Unknown storage driver: " + string(a.Config().Storage.Driver))
}

View File

@@ -0,0 +1,147 @@
package storage
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/fr"
"github.com/tech/sendico/server/internal/server/fileserviceimp/storage/config"
"go.uber.org/zap"
)
type LocalStorage struct {
logger mlogger.Logger
storageDir string
subDir string
directory string
dp domainprovider.DomainProvider
service mservice.Type
}
func (storage *LocalStorage) Delete(ctx context.Context, objID string) error {
// Check if context is cancelled
select {
case <-ctx.Done():
return ctx.Err()
default:
}
filePath := filepath.Join(storage.storageDir, objID)
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
storage.logger.Debug("File not found", zap.String("obj_ref", objID))
return merrors.NoData("file_not_found")
}
storage.logger.Warn("Error occurred while accesing file", zap.Error(err), zap.String("storage", storage.storageDir), zap.String("obj_ref", objID))
return err
}
return nil
}
func (storage *LocalStorage) Save(ctx context.Context, file io.Reader, objID string) (string, error) {
// Check if context is cancelled
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
filePath := filepath.Join(storage.storageDir, objID)
dst, err := os.Create(filePath)
if err != nil {
storage.logger.Warn("Error occurred while creating file", zap.Error(err), zap.String("storage", storage.storageDir), zap.String("obj_ref", objID))
return "", err
}
defer fr.CloseFile(storage.logger, dst)
// Use a goroutine to copy the file and monitor context cancellation
errCh := make(chan error, 1)
go func() {
_, err := io.Copy(dst, file)
errCh <- err
}()
// Wait for either completion or context cancellation
select {
case err := <-errCh:
if err != nil {
storage.logger.Warn("Error occurred while saving file", zap.Error(err), zap.String("obj_ref", objID))
return "", err
}
case <-ctx.Done():
// Context was cancelled, clean up the partial file
os.Remove(filePath)
return "", ctx.Err()
}
return storage.dp.GetAPILink(storage.directory, storage.subDir, objID)
}
func (storage *LocalStorage) Get(ctx context.Context, objRef string) http.HandlerFunc {
// Check if context is cancelled
select {
case <-ctx.Done():
return response.Internal(storage.logger, storage.service, ctx.Err())
default:
}
filePath := filepath.Join(storage.storageDir, objRef)
if _, err := os.Stat(filePath); err != nil {
storage.logger.Warn("Failed to access file", zap.Error(err), zap.String("storage", storage.storageDir), zap.String("obj_ref", objRef))
return response.Internal(storage.logger, storage.service, err)
}
res := func(w http.ResponseWriter, r *http.Request) {
// Check if the request context is cancelled
select {
case <-r.Context().Done():
storage.logger.Warn("Request canceleed", zap.Error(r.Context().Err()), zap.String("obj_ref", objRef))
http.Error(w, "Request cancelled", http.StatusRequestTimeout)
return
default:
}
http.ServeFile(w, r, filePath)
}
return res
}
func ensureDir(dirName string) error {
info, err := os.Stat(dirName)
if os.IsNotExist(err) {
return os.MkdirAll(dirName, 0o755)
}
if err != nil {
return err
}
if !info.IsDir() {
return &os.PathError{Op: "mkdir", Path: dirName, Err: os.ErrExist}
}
return nil
}
func CreateLocalFileStorage(logger mlogger.Logger, service mservice.Type, directory, subDir string, dp domainprovider.DomainProvider, cfg config.LocalFSSConfig) (*LocalStorage, error) {
dir := filepath.Join(cfg.RootPath, directory)
if err := ensureDir(dir); err != nil {
logger.Warn("Failed to check directory availability", zap.Error(err), zap.String("dir", dir))
return nil, err
}
res := &LocalStorage{
logger: logger.Named("lfs").Named(directory),
storageDir: dir,
directory: directory,
subDir: subDir,
dp: dp,
service: service,
}
res.logger.Info("Storage installed", zap.String("root_path", cfg.RootPath), zap.String("directory", directory))
return res, nil
}

View File

@@ -0,0 +1,553 @@
package storage
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/internal/server/fileserviceimp/storage/config"
"go.uber.org/zap"
)
// Mock domain provider for testing
type mockDomainProvider struct{}
func (m *mockDomainProvider) GetAPILink(linkElem ...string) (string, error) {
if len(linkElem) == 0 {
return "", fmt.Errorf("no link elements provided")
}
return "/api/v1/files/" + linkElem[len(linkElem)-1], nil
}
func (m *mockDomainProvider) GetFullLink(linkElem ...string) (string, error) {
if len(linkElem) == 0 {
return "", fmt.Errorf("no link elements provided")
}
return "https://test.local/api/v1/files/" + linkElem[len(linkElem)-1], nil
}
func setupTestStorage(t *testing.T) (*LocalStorage, string, func()) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "storage_test")
require.NoError(t, err)
// Create logger
logger := zap.NewNop()
// Create storage
storage := &LocalStorage{
logger: logger.Named("lfs").Named("test"),
storageDir: tempDir,
subDir: "test",
directory: "test",
dp: &mockDomainProvider{},
service: mservice.Storage,
}
// Return cleanup function
cleanup := func() {
os.RemoveAll(tempDir)
}
return storage, tempDir, cleanup
}
func setupBenchmarkStorage(b *testing.B) (*LocalStorage, string, func()) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "storage_bench")
require.NoError(b, err)
// Create logger
logger := zap.NewNop()
// Create storage
storage := &LocalStorage{
logger: logger.Named("lfs").Named("test"),
storageDir: tempDir,
subDir: "test",
directory: "test",
dp: &mockDomainProvider{},
service: mservice.Storage,
}
// Return cleanup function
cleanup := func() {
os.RemoveAll(tempDir)
}
return storage, tempDir, cleanup
}
func TestLocalStorage_Save(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
tests := []struct {
name string
content string
objID string
wantURL string
wantErr bool
}{
{
name: "save simple file",
content: "test content",
objID: "test.txt",
wantURL: "/api/v1/files/test.txt",
wantErr: false,
},
{
name: "save with special characters",
content: "special content",
objID: "test-file_123.txt",
wantURL: "/api/v1/files/test-file_123.txt",
wantErr: false,
},
{
name: "save empty file",
content: "",
objID: "empty.txt",
wantURL: "/api/v1/files/empty.txt",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
reader := strings.NewReader(tt.content)
url, err := storage.Save(ctx, reader, tt.objID)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantURL, url)
// Verify file was actually saved
filePath := filepath.Join(tempDir, tt.objID)
content, err := os.ReadFile(filePath)
assert.NoError(t, err)
assert.Equal(t, tt.content, string(content))
})
}
}
func TestLocalStorage_Save_ContextCancellation(t *testing.T) {
storage, _, cleanup := setupTestStorage(t)
defer cleanup()
// Create a context that's already cancelled
ctx, cancel := context.WithCancel(context.Background())
cancel()
reader := strings.NewReader("test content")
url, err := storage.Save(ctx, reader, "test.txt")
assert.Error(t, err)
assert.Equal(t, context.Canceled, err)
assert.Empty(t, url)
}
func TestLocalStorage_Save_ContextTimeout(t *testing.T) {
storage, _, cleanup := setupTestStorage(t)
defer cleanup()
// Create a context with a very short timeout
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
defer cancel()
// Wait a bit to ensure timeout
time.Sleep(1 * time.Millisecond)
reader := strings.NewReader("test content")
url, err := storage.Save(ctx, reader, "test.txt")
assert.Error(t, err)
assert.Equal(t, context.DeadlineExceeded, err)
assert.Empty(t, url)
}
func TestLocalStorage_Delete(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0o644)
require.NoError(t, err)
tests := []struct {
name string
objID string
wantErr bool
}{
{
name: "delete existing file",
objID: "test.txt",
wantErr: false,
},
{
name: "delete non-existent file",
objID: "nonexistent.txt",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
err := storage.Delete(ctx, tt.objID)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
// Verify file was actually deleted
filePath := filepath.Join(tempDir, tt.objID)
_, err = os.Stat(filePath)
assert.True(t, os.IsNotExist(err))
})
}
}
func TestLocalStorage_Delete_ContextCancellation(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0o644)
require.NoError(t, err)
// Create a context that's already cancelled
ctx, cancel := context.WithCancel(context.Background())
cancel()
err = storage.Delete(ctx, "test.txt")
assert.Error(t, err)
assert.Equal(t, context.Canceled, err)
// File should still exist since operation was cancelled
_, err = os.Stat(testFile)
assert.NoError(t, err)
}
func TestLocalStorage_Get(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a test file
testContent := "test file content"
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte(testContent), 0o644)
require.NoError(t, err)
tests := []struct {
name string
objID string
wantStatusCode int
wantContent string
}{
{
name: "get existing file",
objID: "test.txt",
wantStatusCode: http.StatusOK,
wantContent: testContent,
},
{
name: "get non-existent file",
objID: "nonexistent.txt",
wantStatusCode: http.StatusInternalServerError,
wantContent: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
handler := storage.Get(ctx, tt.objID)
// Create test request
req := httptest.NewRequest("GET", "/files/"+tt.objID, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, tt.wantStatusCode, w.Code)
if tt.wantContent != "" {
assert.Equal(t, tt.wantContent, w.Body.String())
}
})
}
}
func TestLocalStorage_Get_ContextCancellation(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0o644)
require.NoError(t, err)
// Create a context that's already cancelled
ctx, cancel := context.WithCancel(context.Background())
cancel()
handler := storage.Get(ctx, "test.txt")
// Create test request
req := httptest.NewRequest("GET", "/files/test.txt", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
}
func TestLocalStorage_Get_RequestContextCancellation(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0o644)
require.NoError(t, err)
ctx := context.Background()
handler := storage.Get(ctx, "test.txt")
// Create test request with cancelled context
req := httptest.NewRequest("GET", "/files/test.txt", nil)
reqCtx, cancel := context.WithCancel(req.Context())
req = req.WithContext(reqCtx)
cancel() // Cancel the request context
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusRequestTimeout, w.Code)
assert.Contains(t, w.Body.String(), "Request cancelled")
}
func TestCreateLocalFileStorage(t *testing.T) {
// Create temporary directory for testing
tempDir, err := os.MkdirTemp("", "storage_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
logger := zap.NewNop()
cfg := config.LocalFSSConfig{
RootPath: tempDir,
}
storage, err := CreateLocalFileStorage(logger, mservice.Storage, "test", "sub", &mockDomainProvider{}, cfg)
assert.NoError(t, err)
assert.NotNil(t, storage)
assert.Equal(t, filepath.Join(tempDir, "test"), storage.storageDir)
assert.Equal(t, "test", storage.directory)
assert.Equal(t, "sub", storage.subDir)
}
func TestCreateLocalFileStorage_InvalidPath(t *testing.T) {
// Build a deterministic failure case: the target path already exists as a file.
tempDir, err := os.MkdirTemp("", "storage_invalid_path_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
fileAtTargetPath := filepath.Join(tempDir, "test")
err = os.WriteFile(fileAtTargetPath, []byte("not a directory"), 0o644)
require.NoError(t, err)
logger := zap.NewNop()
cfg := config.LocalFSSConfig{
RootPath: tempDir,
}
storage, err := CreateLocalFileStorage(logger, mservice.Storage, "test", "sub", &mockDomainProvider{}, cfg)
assert.Error(t, err)
assert.Nil(t, storage)
}
func TestLocalStorage_ConcurrentOperations(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Test concurrent saves
t.Run("concurrent saves", func(t *testing.T) {
const numGoroutines = 10
errCh := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
ctx := context.Background()
content := strings.NewReader(fmt.Sprintf("content %d", id))
_, err := storage.Save(ctx, content, fmt.Sprintf("file_%d.txt", id))
errCh <- err
}(i)
}
// Collect results
for i := 0; i < numGoroutines; i++ {
err := <-errCh
assert.NoError(t, err)
}
// Verify all files were created
for i := 0; i < numGoroutines; i++ {
filePath := filepath.Join(tempDir, fmt.Sprintf("file_%d.txt", i))
_, err := os.Stat(filePath)
assert.NoError(t, err)
}
})
// Test concurrent deletes
t.Run("concurrent deletes", func(t *testing.T) {
// Create files to delete
for i := 0; i < 5; i++ {
filePath := filepath.Join(tempDir, fmt.Sprintf("delete_%d.txt", i))
err := os.WriteFile(filePath, []byte("content"), 0o644)
require.NoError(t, err)
}
const numGoroutines = 5
errCh := make(chan error, numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(id int) {
ctx := context.Background()
err := storage.Delete(ctx, fmt.Sprintf("delete_%d.txt", id))
errCh <- err
}(i)
}
// Collect results
for i := 0; i < numGoroutines; i++ {
err := <-errCh
assert.NoError(t, err)
}
// Verify all files were deleted
for i := 0; i < numGoroutines; i++ {
filePath := filepath.Join(tempDir, fmt.Sprintf("delete_%d.txt", i))
_, err := os.Stat(filePath)
assert.True(t, os.IsNotExist(err))
}
})
}
func TestLocalStorage_LargeFile(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Create a large content (1MB)
largeContent := strings.Repeat("a", 1024*1024)
reader := strings.NewReader(largeContent)
ctx := context.Background()
url, err := storage.Save(ctx, reader, "large.txt")
assert.NoError(t, err)
assert.Equal(t, "/api/v1/files/large.txt", url)
// Verify file size
filePath := filepath.Join(tempDir, "large.txt")
info, err := os.Stat(filePath)
assert.NoError(t, err)
assert.Equal(t, int64(1024*1024), info.Size())
}
func TestLocalStorage_SpecialCharacters(t *testing.T) {
storage, tempDir, cleanup := setupTestStorage(t)
defer cleanup()
// Test with special characters in filename
specialNames := []string{
"file with spaces.txt",
"file-with-dashes.txt",
"file_with_underscores.txt",
"file.with.dots.txt",
"file(1).txt",
"file[1].txt",
"file{1}.txt",
"file@#$%.txt",
}
for _, name := range specialNames {
t.Run("special characters: "+name, func(t *testing.T) {
ctx := context.Background()
content := strings.NewReader("test content")
url, err := storage.Save(ctx, content, name)
assert.NoError(t, err)
assert.Equal(t, "/api/v1/files/"+name, url)
// Verify file exists
filePath := filepath.Join(tempDir, name)
_, err = os.Stat(filePath)
assert.NoError(t, err)
})
}
}
// Benchmark tests
func BenchmarkLocalStorage_Save(b *testing.B) {
storage, _, cleanup := setupBenchmarkStorage(b)
defer cleanup()
content := strings.Repeat("test content ", 1000) // ~13KB
reader := strings.NewReader(content)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := context.Background()
_, err := storage.Save(ctx, reader, fmt.Sprintf("bench_%d.txt", i))
if err != nil {
b.Fatal(err)
}
reader.Reset(content)
}
}
func BenchmarkLocalStorage_Delete(b *testing.B) {
storage, tempDir, cleanup := setupBenchmarkStorage(b)
defer cleanup()
// Pre-create files for deletion
for i := 0; i < b.N; i++ {
filePath := filepath.Join(tempDir, fmt.Sprintf("bench_delete_%d.txt", i))
err := os.WriteFile(filePath, []byte("content"), 0o644)
if err != nil {
b.Fatal(err)
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := context.Background()
err := storage.Delete(ctx, fmt.Sprintf("bench_delete_%d.txt", i))
if err != nil {
b.Fatal(err)
}
}
}

View File

@@ -0,0 +1,13 @@
package storage
import (
"context"
"io"
"net/http"
)
type FileManager interface {
Save(ctx context.Context, file io.Reader, objID string) (string, error)
Get(ctx context.Context, objID string) http.HandlerFunc
Delete(ctx context.Context, objID string) error
}

View File

@@ -0,0 +1,120 @@
package serverimp
import (
"context"
"errors"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"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"
ac "github.com/tech/sendico/server/interface/api"
apiimip "github.com/tech/sendico/server/internal/api"
"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 *ac.Config `yaml:"api"`
DB *db.Config `yaml:"database"`
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
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
}
router := chi.NewRouter()
if i.api, err = apiimip.CreateAPI(i.logger, i.config.API, i.db, router, i.debug); err != nil {
i.logger.Error("Failed to create API instance", zap.Error(err))
return err
}
// Startup the HTTP Server in a way that we can gracefully shut it down again
i.httpServer = &http.Server{
Addr: i.config.HTTPServer.ListenAddress,
Handler: router,
ReadHeaderTimeout: mduration.Param2Duration(i.config.HTTPServer.ReadHeaderTimeout, time.Second),
}
return i.Run()
}
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
srv := &Imp{
logger: logger,
debug: debug,
file: file,
}
return srv, nil
}

View File

@@ -0,0 +1,136 @@
package invitationimp
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
an "github.com/tech/sendico/pkg/messaging/notifications/account"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *InvitationAPI) doAccept(ctx context.Context, invitationRef bson.ObjectID, accData *model.AccountData) error {
inv, err := a.getPendingInvitation(ctx, invitationRef)
if err != nil {
return err
}
org, err := a.getOrganization(ctx, inv.OrganizationRef, inv.Content.Email)
if err != nil {
return err
}
if _, err := a.fetchOrCreateAccount(ctx, org, inv, accData); err != nil {
return err
}
if err := a.db.Accept(ctx, invitationRef); err != nil {
a.Logger.Warn("Failed to accept invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return err
}
return nil
}
func (a *InvitationAPI) sendWelcomeEmail(account *model.Account, token string) error {
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID(), token)); err != nil {
a.Logger.Warn("Failed to send account creation notification", zap.Error(err))
return err
}
return nil
}
func (a *InvitationAPI) getPendingInvitation(ctx context.Context, invitationRef bson.ObjectID) (*model.Invitation, error) {
a.Logger.Debug("Fetching invitation", mzap.ObjRef("invitation_ref", invitationRef))
var inv model.Invitation
if err := a.db.Unprotected().Get(ctx, invitationRef, &inv); err != nil {
a.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return nil, err
}
if inv.Status != model.InvitationCreated {
a.Logger.Warn("Invitation is not pending", mzap.StorableRef(&inv))
return nil, merrors.InvalidArgument("Invitation is not pending")
}
return &inv, nil
}
func (a *InvitationAPI) getOrganization(ctx context.Context, orgRef bson.ObjectID, email string) (*model.Organization, error) {
a.Logger.Debug("Fetching organization", mzap.ObjRef("organization_ref", orgRef), zap.String("email", email))
var org model.Organization
if err := a.odb.Unprotected().Get(ctx, orgRef, &org); err != nil {
a.Logger.Warn("Failed to fetch organization when processing invitation", zap.Error(err),
mzap.ObjRef("organization_ref", orgRef), zap.String("email", email))
return nil, err
}
return &org, nil
}
func (a *InvitationAPI) fetchOrCreateAccount(ctx context.Context, org *model.Organization, inv *model.Invitation, accData *model.AccountData) (*model.Account, error) {
account, err := a.adb.GetByEmail(ctx, inv.Content.Email)
if errors.Is(err, merrors.ErrNoData) {
a.Logger.Debug("Account is not registered, creating", zap.String("email", inv.Content.Email))
if accData == nil {
a.Logger.Warn("Account data missing for unregistered invitation acceptance",
zap.String("email", inv.Content.Email), mzap.StorableRef(inv))
return nil, merrors.InvalidArgument("No account data provided for invitation acceptance")
}
account = accData.ToAccount()
if err := a.accService.ValidateAccount(account); err != nil {
a.Logger.Info("Account validation failed", zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
}
// creates account and joins organization
token, err := a.accService.CreateAccount(ctx, org, account, inv.RoleRef)
if err != nil {
a.Logger.Warn("Failed to create account", zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
}
// Send welcome email
if err = a.sendWelcomeEmail(account, token); err != nil {
a.Logger.Warn("Failed to send welcome email for new account created via invitation",
zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
}
return account, nil
} else if err != nil {
a.Logger.Warn("Failed to fetch account by email", zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
} else {
// If account already exists, then just join organization
if err := a.accService.JoinOrganization(ctx, org, account, inv.RoleRef); err != nil {
a.Logger.Warn("Failed to join organization", zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return nil, err
}
}
return account, nil
}
func (a *InvitationAPI) accept(r *http.Request) http.HandlerFunc {
invitationRef, err := a.irh.GetRef(r)
if err != nil {
return a.respondBadReference(r, err)
}
var req srequest.AcceptInvitation
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.Logger.Warn("Failed to decode request body", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.BadPayload(a.Logger, a.Name(), err)
}
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
return nil, a.doAccept(ctx, invitationRef, req.Account)
}); err != nil {
a.Logger.Warn("Failed to accept invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.Auto(a.Logger, a.Name(), err)
}
return response.Success(a.Logger)
}

View File

@@ -0,0 +1,24 @@
package invitationimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
func (a *InvitationAPI) decline(r *http.Request) http.HandlerFunc {
invitationRef, err := a.irh.GetRef(r)
if err != nil {
return a.respondBadReference(r, err)
}
ctx := r.Context()
if err := a.db.Decline(ctx, invitationRef); err != nil {
a.Logger.Warn("Failed to decline invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.Auto(a.Logger, a.Name(), err)
}
return response.Success(a.Logger)
}

View File

@@ -0,0 +1,19 @@
package invitationimp
import (
messaging "github.com/tech/sendico/pkg/messaging/envelope"
in "github.com/tech/sendico/pkg/messaging/notifications/invitation"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (a *InvitationAPI) notification(
invitation *model.Invitation,
actorAccountRef bson.ObjectID,
t nm.NotificationAction,
) messaging.Envelope {
a.Logger.Debug("Sending notification of new invitation created", mzap.StorableRef(invitation))
return in.Invitation(a.Name(), actorAccountRef, invitation.ID, t)
}

View File

@@ -0,0 +1,26 @@
package invitationimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *InvitationAPI) public(r *http.Request) http.HandlerFunc {
invitationRef, err := a.irh.GetRef(r)
if err != nil {
return a.respondBadReference(r, err)
}
ctx := r.Context()
inv, err := a.db.GetPublic(ctx, invitationRef)
if err != nil {
a.Logger.Warn("Failed to get public invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.Auto(a.Logger, a.Name(), err)
}
return sresponse.Invitation(a.Logger, inv)
}

View File

@@ -0,0 +1,13 @@
package invitationimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
mutil "github.com/tech/sendico/server/internal/mutil/param"
)
func (a *InvitationAPI) respondBadReference(r *http.Request, err error) http.HandlerFunc {
a.Logger.Warn("Failed to fetch invitation reference", mutil.PLog(a.irh, r))
return response.BadReference(a.Logger, a.Name(), a.irh.Name(), a.irh.GetID(r), err)
}

View File

@@ -0,0 +1,84 @@
package invitationimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/invitation"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/accountservice"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"github.com/tech/sendico/server/internal/server/papitemplate"
"go.uber.org/zap"
)
type InvitationAPI struct {
papitemplate.ProtectedAPI[model.Invitation]
db invitation.DB
irh mutil.ParamHelper
tf transaction.Factory
adb account.DB
odb organization.DB
accService accountservice.AccountService
producer messaging.Producer
}
func (a *InvitationAPI) Name() mservice.Type {
return mservice.Invitations
}
func (a *InvitationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*InvitationAPI, error) {
dbFactory := func() (papitemplate.ProtectedDB[model.Invitation], error) {
return a.DBFactory().NewInvitationsDB()
}
res := &InvitationAPI{
irh: mutil.CreatePH("invitation"),
tf: a.DBFactory().TransactionFactory(),
producer: a.Register().Messaging().Producer(),
}
p, err := papitemplate.CreateAPI(a, dbFactory, mservice.Organizations, mservice.Invitations)
if err != nil {
return nil, err
}
res.ProtectedAPI = *p.WithNotifications(res.notification).Build()
if res.db, err = a.DBFactory().NewInvitationsDB(); err != nil {
res.Logger.Warn("Failed to create invitation database", zap.Error(err))
return nil, err
}
if res.adb, err = a.DBFactory().NewAccountDB(); err != nil {
res.Logger.Warn("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
res.Logger.Warn("Failed to create organizations database", zap.Error(err))
return nil, err
}
if res.accService, err = accountservice.NewAccountService(
res.Logger,
a.DBFactory(),
a.Permissions().Enforcer(),
a.Permissions().Manager().Role(),
&a.Config().Mw.Password); err != nil {
res.Logger.Warn("Failed to create account service", zap.Error(err))
return nil, err
}
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/public"), api.Get, res.public)
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/accept"), api.Put, res.accept)
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/decline"), api.Delete, res.decline)
return res, nil
}

View File

@@ -0,0 +1,53 @@
package ledgerapiimp
import (
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for ledger balance", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
accountRef := strings.TrimSpace(a.aph.GetID(r))
if accountRef == "" {
return response.BadReference(a.logger, a.Name(), a.aph.Name(), a.aph.GetID(r), merrors.InvalidArgument("ledger account reference is required"))
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.balancePerm, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check ledger balance access permissions", zap.Error(err), mutil.PLog(a.oph, r), zap.String("ledger_account_ref", accountRef))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading ledger balance", mutil.PLog(a.oph, r), zap.String("ledger_account_ref", accountRef))
return response.AccessDenied(a.logger, a.Name(), "ledger balance read permission denied")
}
if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
}
resp, err := a.client.GetBalance(ctx, &ledgerv1.GetBalanceRequest{
LedgerAccountRef: accountRef,
})
if err != nil {
a.logger.Warn("Failed to fetch ledger balance", zap.Error(err), zap.String("ledger_account_ref", accountRef))
return response.Auto(a.logger, mservice.Ledger, err)
}
return sresponse.LedgerBalance(a.logger, resp, token)
}

View File

@@ -0,0 +1,146 @@
package ledgerapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/mservice"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for ledger account create", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when creating ledger account", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "ledger accounts write permission denied")
}
payload, err := decodeLedgerAccountCreatePayload(r)
if err != nil {
a.logger.Warn("Failed to decode ledger account create payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
accountType, err := mapLedgerAccountType(payload.AccountType)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
accountRole, err := mapLedgerAccountRole(payload.Role)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
}
var describable *describablev1.Describable
name := strings.TrimSpace(payload.Describable.Name)
var description *string
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed != "" {
description = &trimmed
}
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
var ownerRef string
if payload.OwnerRef != nil && !payload.OwnerRef.IsZero() {
ownerRef = payload.OwnerRef.Hex()
}
resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef,
AccountType: accountType,
Currency: payload.Currency,
Status: ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE,
AllowNegative: payload.AllowNegative,
Role: accountRole,
Metadata: payload.Metadata,
Describable: describable,
})
if err != nil {
a.logger.Warn("Failed to create ledger account", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, mservice.Ledger, err)
}
return sresponse.LedgerAccountCreated(a.logger, resp.GetAccount(), token)
}
func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) {
defer r.Body.Close()
payload := srequest.CreateLedgerAccount{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency))
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed == "" {
payload.Describable.Description = nil
} else {
payload.Describable.Description = &trimmed
}
}
if len(payload.Metadata) == 0 {
payload.Metadata = nil
}
if err := payload.Validate(); err != nil {
return nil, err
}
return &payload, nil
}
func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) {
raw := string(accountType)
if ledgerconv.IsAccountTypeUnspecified(raw) {
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("accountType is required", "accountType")
}
parsed, ok := ledgerconv.ParseAccountType(raw)
if !ok {
return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, merrors.InvalidArgument("unsupported accountType: "+string(accountType), "accountType")
}
return parsed, nil
}
func mapLedgerAccountRole(role account_role.AccountRole) (ledgerv1.AccountRole, error) {
raw := strings.TrimSpace(string(role))
if ledgerconv.IsAccountRoleUnspecified(raw) {
return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, nil
}
parsed, ok := ledgerconv.ParseAccountRole(raw)
if !ok {
return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED, merrors.InvalidArgument("unsupported role: "+raw, "role")
}
return parsed, nil
}

View File

@@ -0,0 +1,55 @@
package ledgerapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/wrapperspb"
)
func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for ledger account list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
hasReadPermission, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if a.client == nil {
return response.Internal(a.logger, mservice.Ledger, merrors.Internal("ledger client is not configured"))
}
req := &ledgerv1.ListAccountsRequest{
OrganizationRef: orgRef.Hex(),
}
// If user has read permission, return all accounts in organization.
// Otherwise, filter to only accounts owned by the requesting account.
if !hasReadPermission {
req.OwnerRefFilter = wrapperspb.String(account.ID.Hex())
a.logger.Debug("Filtering ledger accounts by owner due to limited permissions",
mzap.ObjRef("owner_ref", account.ID), mutil.PLog(a.oph, r))
}
resp, err := a.client.ListAccounts(ctx, req)
if err != nil {
a.logger.Warn("Failed to list ledger accounts", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, mservice.Ledger, err)
}
return sresponse.LedgerAccounts(a.logger, resp.GetAccounts(), token)
}

View File

@@ -0,0 +1,112 @@
package ledgerapiimp
import (
"context"
"fmt"
"os"
"strings"
"time"
ledgerclient "github.com/tech/sendico/ledger/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type ledgerClient interface {
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
Close() error
}
type LedgerAPI struct {
logger mlogger.Logger
client ledgerClient
enf auth.Enforcer
oph mutil.ParamHelper
aph mutil.ParamHelper
permissionRef bson.ObjectID
balancePerm bson.ObjectID
}
func (a *LedgerAPI) Name() mservice.Type { return mservice.LedgerAccounts }
func (a *LedgerAPI) Finish(ctx context.Context) error {
if a.client != nil {
if err := a.client.Close(); err != nil {
a.logger.Warn("Failed to close ledger client", zap.Error(err))
}
}
return nil
}
func CreateAPI(apiCtx eapi.API) (*LedgerAPI, error) {
p := &LedgerAPI{
logger: apiCtx.Logger().Named(mservice.LedgerAccounts),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
aph: mutil.CreatePH("ledger_account"),
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.LedgerAccounts)
if err != nil {
p.logger.Warn("Failed to fetch ledger accounts permission description", zap.Error(err))
return nil, err
}
p.permissionRef = desc.ID
bdesc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.LedgerBalances)
if err != nil {
p.logger.Warn("Failed to fetch ledger balances permission description", zap.Error(err))
return nil, err
}
p.balancePerm = bdesc.ID
if err := p.initLedgerClient(apiCtx.Config().Ledger); err != nil {
p.logger.Error("Failed to initialize ledger client", zap.Error(err))
return nil, err
}
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listAccounts)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.createAccount)
apiCtx.Register().AccountHandler(p.Name(), p.aph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getBalance)
return p, nil
}
func (a *LedgerAPI) initLedgerClient(cfg *eapi.LedgerConfig) error {
if cfg == nil {
return merrors.InvalidArgument("ledger configuration is not provided")
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
}
if address == "" {
return merrors.InvalidArgument(fmt.Sprintf("ledger address is not specified and address env %s is empty", cfg.AddressEnv))
}
clientCfg := ledgerclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := ledgerclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
a.client = client
return nil
}

View File

@@ -0,0 +1,40 @@
package logoimp
import (
_ "embed"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/assets"
"github.com/tech/sendico/server/internal/mutil/imagewriter"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *LogoAPI) getLogo(r *http.Request) http.HandlerFunc {
accountRef, err := mutil.GetAccountRef(r)
if err != nil {
a.logger.Warn("Invalid account reference", zap.Error(err))
return response.BadReference(a.logger, a.Name(), mutil.AccountRefName(), mutil.GetAccountID(r), err)
}
// new context for execution
var account model.Account
if err := a.adb.Get(r.Context(), accountRef, &account); err != nil {
a.logger.Warn("Failed to fetch account data", zap.Error(err))
return response.NotFound(a.logger, mservice.Accounts, err.Error())
}
res := func(w http.ResponseWriter, r *http.Request) {
// TODO: delayed response due to context expiration after writing reposnse :(
if err = imagewriter.WriteImage(w, &assets.MailLogo, "image/png"); err != nil {
a.logger.Error("Failed to send logo", zap.Error(err))
}
// ma.Identify(acc.Email)
// ampli.Instance.EmailOpened(acc.Email,
// ampli.EmailOpened.Builder().EmailType(mutil.GetParam(r, "email_type")).Build())
}
return res
}

View File

@@ -0,0 +1,40 @@
package logoimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
type LogoAPI struct {
logger mlogger.Logger
adb account.DB
}
func (a *LogoAPI) Name() mservice.Type {
return mservice.Logo
}
func (a *LogoAPI) Finish(ctx context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*LogoAPI, error) {
p := new(LogoAPI)
p.logger = a.Logger().Named(p.Name())
var err error
if p.adb, err = a.DBFactory().NewAccountDB(); err != nil {
p.logger.Error("Failed to create account database", zap.Error(err))
return nil, err
}
a.Register().Handler(mservice.Logo, mutil.AddAccountRef("/")+"/{email_type}", api.Get, p.getLogo)
return p, nil
}

View File

@@ -0,0 +1,71 @@
package organizationimp
import (
"encoding/json"
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *OrganizationAPI) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
orgs, err := a.db.List(r.Context(), *account.GetID(), nil)
if errors.Is(err, merrors.ErrNoData) || (len(orgs) == 0) {
a.logger.Debug("Organizations not found", zap.Error(err), mzap.StorableRef(account))
return response.NotFound(a.logger, a.Name(), "orgnizations not found")
}
if err != nil {
a.logger.Warn("Failed to fetch organizations", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.Organizations(a.logger, orgs, accessToken)
}
func (a *OrganizationAPI) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
orgRef, err := mutil.GetOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization id", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
}
var org model.Organization
if err := a.db.Get(r.Context(), *account.GetID(), orgRef, &org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Organization not found", mzap.ObjRef("organization_ref", orgRef), zap.Error(err))
return response.NotFound(a.logger, a.Name(), "organization with given id not found")
}
a.logger.Error("Error fetching organization", mzap.ObjRef("organization_ref", orgRef), zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.Organization(a.logger, &org, accessToken)
}
func (a *OrganizationAPI) update(r *http.Request, acccount *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var org model.OrganizationBase
if err := json.NewDecoder(r.Body).Decode(&org); err != nil {
a.logger.Warn("Failed to decode organization when updating settings", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
ctx := r.Context()
var orgStored model.Organization
if err := a.db.Get(ctx, *acccount.GetID(), *org.GetID(), &orgStored); err != nil {
a.logger.Warn("Failed to fetch organization for update", zap.Error(err), mzap.StorableRef(&org), mzap.StorableRef(acccount))
return response.Auto(a.logger, a.Name(), err)
}
orgStored.OrganizationBase = org
if err := a.db.Update(r.Context(), *acccount.GetID(), &orgStored); err != nil {
a.logger.Warn("Error fetching organization", mzap.StorableRef(&org), zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.Organization(a.logger, &orgStored, accessToken)
}

View File

@@ -0,0 +1,37 @@
package organizationimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *OrganizationAPI) invitation(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
invitationRef, err := mutil.GetInvitationRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization id", zap.Error(err), zap.String("invitation_ref", mutil.GetOrganizationID(r)))
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("bad invitation reference"))
}
ctx := r.Context()
var invitation model.Invitation
if err := a.idb.Get(ctx, *account.GetID(), invitationRef, &invitation); err != nil {
a.logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.Auto(a.logger, a.Name(), err)
}
var org model.Organization
if err := a.db.Get(ctx, *account.GetID(), invitation.OrganizationRef, &org); err != nil {
a.logger.Error("Error fetching organization", zap.Error(err),
mzap.StorableRef(&invitation), mzap.ObjRef("organization_ref", invitation.OrganizationRef))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.OrganizationPublic(a.logger, &org.OrganizationBase)
}

View File

@@ -0,0 +1,59 @@
package organizationimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/invitation"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/fileservice"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
type OrganizationAPI struct {
logger mlogger.Logger
db organization.DB
idb invitation.DB
images mservice.MicroService
enforcer auth.Enforcer
}
func (a *OrganizationAPI) Name() mservice.Type {
return mservice.Organizations
}
func (a *OrganizationAPI) Finish(ctx context.Context) error {
return a.images.Finish(ctx)
}
func CreateAPI(a eapi.API) (*OrganizationAPI, error) {
p := new(OrganizationAPI)
p.logger = a.Logger().Named(p.Name())
p.enforcer = a.Permissions().Enforcer()
var err error
if p.db, err = a.DBFactory().NewOrganizationDB(); err != nil {
p.logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
if p.idb, err = a.DBFactory().NewInvitationsDB(); err != nil {
p.logger.Error("Failed to create invitations database", zap.Error(err))
return nil, err
}
a.Register().AccountHandler(mservice.Organizations, "", api.Get, p.list)
a.Register().AccountHandler(mservice.Organizations, mutil.AddOrganizaztionRef("/"), api.Get, p.get)
a.Register().AccountHandler(mservice.Organizations, "", api.Put, p.update)
a.Register().AccountHandler(mservice.Organizations, mutil.AddInvitationRef("/invitation"), api.Get, p.invitation)
if p.images, err = fileservice.CreateAPI(a, p.Name()); err != nil {
p.logger.Error("Failed to create image server", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,69 @@
package papitemplate
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) archive(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
objectRef, err := a.Cph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
}
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
archived, err := mutil.GetArchiveParam(a.Logger, r)
if err != nil {
a.Logger.Warn("Failed to read optional 'archived' param", zap.Error(err))
return response.Auto(a.Logger, a.resource, err)
}
if archived == nil {
a.Logger.Warn("No archivation setting provided")
return response.BadRequest(a.Logger, a.resource, "invalid_query_parameter", "'archived' pram must be present")
}
cascade, err := mutil.GetCascadeParam(a.Logger, r)
if err != nil {
a.Logger.Warn("Failed to read optional 'cascade' param", zap.Error(err))
return response.Auto(a.Logger, a.resource, err)
}
if cascade == nil {
a.Logger.Warn("Cascade property not specified, defaulting to false")
csc := false
cascade = &csc
}
ctx := r.Context()
_, err = a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
return nil, a.DB.SetArchived(r.Context(), *account.GetID(), organizationRef, objectRef, *archived, *cascade)
})
if err != nil {
a.Logger.Warn("Failed to change archive property", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r),
zap.Bool("archived", *archived), zap.Bool("cascade", *cascade))
return response.Auto(a.Logger, a.Name(), err)
}
if a.nconfig.NeedArchiveNotification {
var object T
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
} else {
if err := a.nconfig.ArchiveNotification(&object, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send archivation notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
}
}
}
return a.Objects([]T{}, accessToken)
}

View File

@@ -0,0 +1,133 @@
package papitemplate
import (
"github.com/tech/sendico/server/interface/api/sresponse"
)
type HandlerResolver func(sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc
type Config interface {
WithNoCreate() Config
WithCreateHandler(handler sresponse.AccountHandlerFunc) Config
WithNoList() Config
WithListHandler(handler sresponse.AccountHandlerFunc) Config
WithNoGet() Config
WithGetHandler(handler sresponse.AccountHandlerFunc) Config
WithNoUpdate() Config
WithUpdateHandler(handler sresponse.AccountHandlerFunc) Config
WithNoDelete() Config
WithDeleteHandler(handler sresponse.AccountHandlerFunc) Config
WithReorderHandler(reorder ReorderConfig) Config
WithTaggableHandler(taggable TaggableConfig) Config
}
type PAPIConfig struct {
CreateResolver HandlerResolver
ListResolver HandlerResolver
GetResolver HandlerResolver
UpdateResolver HandlerResolver
DeleteResolver HandlerResolver
ArchiveResolver HandlerResolver
Reorder *ReorderConfig
Taggable *TaggableConfig
}
// WithNoCreate disables the create endpoint by replacing its resolver.
func (cfg *PAPIConfig) WithNoCreate() *PAPIConfig {
cfg.CreateResolver = disableResolver
return cfg
}
// WithCreateHandler overrides the create endpoint by replacing its resolver.
func (cfg *PAPIConfig) WithCreateHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.CreateResolver = overrideResolver(handler)
return cfg
}
// WithNoList disables the list endpoint.
func (cfg *PAPIConfig) WithNoList() *PAPIConfig {
cfg.ListResolver = disableResolver
return cfg
}
// WithListHandler overrides the list endpoint.
func (cfg *PAPIConfig) WithListHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.ListResolver = overrideResolver(handler)
return cfg
}
// WithNoGet disables the get endpoint.
func (cfg *PAPIConfig) WithNoGet() *PAPIConfig {
cfg.GetResolver = disableResolver
return cfg
}
// WithGetHandler overrides the get endpoint.
func (cfg *PAPIConfig) WithGetHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.GetResolver = overrideResolver(handler)
return cfg
}
// WithNoUpdate disables the update endpoint.
func (cfg *PAPIConfig) WithNoUpdate() *PAPIConfig {
cfg.UpdateResolver = disableResolver
return cfg
}
// WithUpdateHandler overrides the update endpoint.
func (cfg *PAPIConfig) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.UpdateResolver = overrideResolver(handler)
return cfg
}
// WithNoDelete disables the delete endpoint.
func (cfg *PAPIConfig) WithNoDelete() *PAPIConfig {
cfg.DeleteResolver = disableResolver
return cfg
}
// WithDeleteHandler overrides the delete endpoint.
func (cfg *PAPIConfig) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.DeleteResolver = overrideResolver(handler)
return cfg
}
func (cfg *PAPIConfig) WithNoArchive() *PAPIConfig {
cfg.ArchiveResolver = disableResolver
return cfg
}
func (cfg *PAPIConfig) WithArchiveHandler(handler sresponse.AccountHandlerFunc) *PAPIConfig {
cfg.ArchiveResolver = overrideResolver(handler)
return cfg
}
// defaultResolver returns the default handler unchanged.
func defaultResolver(defaultHandler sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return defaultHandler
}
// disableResolver always returns nil, disabling the endpoint.
func disableResolver(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return nil
}
// overrideResolver returns a resolver that always returns the given custom handler.
func overrideResolver(custom sresponse.AccountHandlerFunc) HandlerResolver {
return func(_ sresponse.AccountHandlerFunc) sresponse.AccountHandlerFunc {
return custom
}
}
func NewConfig() *PAPIConfig {
return &PAPIConfig{
CreateResolver: defaultResolver,
ListResolver: defaultResolver,
GetResolver: defaultResolver,
UpdateResolver: defaultResolver,
DeleteResolver: defaultResolver,
ArchiveResolver: defaultResolver,
Reorder: nil,
Taggable: nil,
}
}

View File

@@ -0,0 +1,38 @@
package papitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) create(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to parse parent object reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
var object T
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
a.Logger.Warn("Failed to decode object when creating", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r))
return response.BadPayload(a.Logger, a.Name(), err)
}
if err := a.DB.Create(r.Context(), *account.GetID(), organizationRef, &object); err != nil {
a.Logger.Warn("Error creating object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r), mutil.PLog(a.Cph, r))
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.nconfig.CreateNotification(&object, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send creation notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Oph, r), mutil.PLog(a.Cph, r))
}
return a.ObjectCreated(&object, accessToken)
}

View File

@@ -0,0 +1,23 @@
package papitemplate
import (
"context"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ProtectedDB[T any] interface {
Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object *T) error
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result *T) error
Update(ctx context.Context, accountRef bson.ObjectID, object *T) error
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error
SetArchived(ctx context.Context, accountRef, organizationRef, objectRef bson.ObjectID, isArchived, cascade bool) error
List(ctx context.Context, accountRef, organizationRef, parentRef bson.ObjectID, cursor *model.ViewCursor) ([]T, error)
}
type ReorderDB interface {
Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error
}

View File

@@ -0,0 +1,67 @@
package papitemplate
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) deleteImp(ctx context.Context, account *model.Account, objectRef bson.ObjectID, cascade *bool) error {
var err error
if (cascade != nil) && (*cascade) {
_, err = a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(ctx, func(ctx context.Context) (any, error) {
return nil, a.DB.DeleteCascadeAuth(ctx, *account.GetID(), objectRef)
})
} else {
err = a.DB.Delete(ctx, *account.GetID(), objectRef)
}
if err != nil {
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mzap.ObjRef("object_ref", objectRef))
return err
}
return nil
}
func (a *ProtectedAPI[T]) delete(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
objectRef, err := a.Cph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
}
cascade, err := mutil.GetCascadeParam(a.Logger, r)
if err != nil {
a.Logger.Warn("Failed to read optional 'cascade' param", zap.Error(err))
return response.Auto(a.Logger, a.resource, err)
}
var objPtr *T
if a.nconfig.NeedDeleteNotification {
var object T
if err := a.DB.Get(r.Context(), *account.GetID(), objectRef, &object); err != nil {
a.Logger.Warn("Failed to fetch object for notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
} else {
objPtr = &object
}
}
if err := a.deleteImp(r.Context(), account, objectRef, cascade); err != nil {
a.Logger.Warn("Error deleting object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
return response.Auto(a.Logger, a.Name(), err)
}
if objPtr != nil {
if err := a.nconfig.DeleteNotification(objPtr, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send deletion notification", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
}
}
return a.Objects([]T{}, accessToken)
}

View File

@@ -0,0 +1,29 @@
package papitemplate
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
objectRef, err := a.Cph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Cph, r))
return response.BadReference(a.Logger, a.Name(), a.Cph.Name(), a.Cph.GetID(r), err)
}
var object T
if err := a.DB.Get(ctx, *account.GetID(), objectRef, &object); err != nil {
a.Logger.Warn("Failed to fetch object", zap.Error(err), mzap.StorableRef(account), mutil.PLog(a.Cph, r))
return response.Auto(a.Logger, a.Name(), err)
}
return a.Object(&object, accessToken)
}

View File

@@ -0,0 +1,42 @@
package papitemplate
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) list(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
organizationRef, err := a.Oph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore organization reference", zap.Error(err), mutil.PLog(a.Oph, r))
return response.BadReference(a.Logger, a.Name(), a.Oph.Name(), a.Oph.GetID(r), err)
}
parentRef, err := a.Pph.GetRef(r)
if err != nil {
a.Logger.Warn("Failed to restore parent reference", zap.Error(err), mutil.PLog(a.Pph, r))
return response.BadReference(a.Logger, a.Name(), a.Pph.Name(), a.Pph.GetID(r), err)
}
cursor, err := mutil.GetViewCursor(a.Logger, r)
if err != nil {
a.Logger.Warn("Failed to decode view cursor", zap.Error(err))
return response.Auto(a.Logger, a.Name(), err)
}
objects, err := a.DB.List(r.Context(), *account.GetID(), organizationRef, parentRef, cursor)
if err != nil {
if !errors.Is(err, merrors.ErrNoData) {
a.Logger.Warn("Failed to list objects", zap.Error(err), mutil.PLog(a.Pph, r))
return response.Auto(a.Logger, a.Name(), err)
} else {
a.Logger.Debug("No objects available", zap.Error(err), mutil.PLog(a.Pph, r))
}
}
return a.Objects(objects, accessToken)
}

View File

@@ -0,0 +1,88 @@
package papitemplate
import (
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
model "github.com/tech/sendico/pkg/model/notification"
"go.mongodb.org/mongo-driver/v2/bson"
)
// NotificationHandler is a function that processes an object of type T and returns an error.
type NotificationHandler[T any] func(template T, actorAccountRef bson.ObjectID) error
// sinkNotification is the default no-op strategy.
func sinkNotification[T any](_ T, _ bson.ObjectID) error {
return nil
}
// NotificationConfig manages notifications for Create, Update, and Delete operations.
type NotificationConfig[T any] struct {
producer messaging.Producer
// The factory now receives a NotificationAction so it knows which event is being processed.
factory func(template T, actorAccountRef bson.ObjectID, t model.NotificationAction) notifications.Envelope
CreateNotification NotificationHandler[T]
UpdateNotification NotificationHandler[T]
NeedArchiveNotification bool
ArchiveNotification NotificationHandler[T]
NeedDeleteNotification bool
DeleteNotification NotificationHandler[T]
}
// NewNotificationConfig creates a new NotificationConfig with default (no-op) strategies.
func NewNotificationConfig[T any](producer messaging.Producer) *NotificationConfig[T] {
return &NotificationConfig[T]{
producer: producer,
factory: nil, // no factory by default
CreateNotification: sinkNotification[T],
UpdateNotification: sinkNotification[T],
ArchiveNotification: sinkNotification[T],
NeedArchiveNotification: false,
DeleteNotification: sinkNotification[T],
NeedDeleteNotification: false,
}
}
// WithNotifications sets the notification factory and switches all endpoints to the sending strategy.
func (nc *NotificationConfig[T]) WithNotifications(factory func(template T, actorAccountRef bson.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
nc.factory = factory
// Build sending functions for each notification type.
nc.CreateNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
}
nc.UpdateNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
}
nc.ArchiveNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
}
nc.NeedArchiveNotification = true
nc.DeleteNotification = func(template T, actorAccountRef bson.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
}
nc.NeedDeleteNotification = true
return nc
}
// WithNoCreateNotification disables the create notification.
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
nc.CreateNotification = sinkNotification[T]
return nc
}
// WithNoUpdateNotification disables the update notification.
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
nc.UpdateNotification = sinkNotification[T]
return nc
}
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
nc.ArchiveNotification = sinkNotification[T]
return nc
}
// WithNoDeleteNotification disables the delete notification.
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
nc.DeleteNotification = sinkNotification[T]
nc.NeedDeleteNotification = false
return nc
}

View File

@@ -0,0 +1,33 @@
package papitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/server/interface/api/srequest"
)
type ReorderRequestProcessor func(r *http.Request) (*srequest.ReorderX, builder.Query, error)
type ReorderConfig struct {
DB ReorderDB
ReqProcessor ReorderRequestProcessor
}
func (cfg *PAPIConfig) WithReorderHandler(reorder ReorderConfig) *PAPIConfig {
cfg.Reorder = &reorder
if cfg.Reorder.ReqProcessor == nil {
cfg.Reorder.ReqProcessor = defaultRequestProcessor
}
return cfg
}
func defaultRequestProcessor(r *http.Request) (*srequest.ReorderX, builder.Query, error) {
var req srequest.ReorderXDefault
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, nil, err
}
return &req.ReorderX, repository.OrgFilter(req.ParentRef), nil
}

View File

@@ -0,0 +1,33 @@
package papitemplate
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) reorder(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing reorder request...")
req, filter, err := a.config.Reorder.ReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode tasks reorder request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Moving objects", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("new_index", req.To))
if _, err := a.a.DBFactory().TransactionFactory().CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
// reorder is not atomic, so wrappping into transaction
return nil, a.config.Reorder.DB.Reorder(ctx, account.ID, req.ObjectRef, req.To, filter)
}); err != nil {
a.Logger.Warn("Failed to reorder tasks", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("to", req.To))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Reorder request processing complete")
return response.Success(a.Logger)
}

View File

@@ -0,0 +1,19 @@
package papitemplate
import (
"net/http"
"github.com/tech/sendico/server/interface/api/sresponse"
)
func (a *ProtectedAPI[T]) Objects(items []T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectsAuth(a.Logger, items, accessToken, a.Name())
}
func (a *ProtectedAPI[T]) Object(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectAuth(a.Logger, item, accessToken, a.Name())
}
func (a *ProtectedAPI[T]) ObjectCreated(item *T, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.ObjectAuthCreated(a.Logger, item, accessToken, a.Name())
}

View File

@@ -0,0 +1,203 @@
package papitemplate
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
model "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type ProtectedAPI[T any] struct {
Logger mlogger.Logger
DB ProtectedDB[T]
Oph mutil.ParamHelper // org param handler
Pph mutil.ParamHelper // parent object param handler
Cph mutil.ParamHelper // child object param handler
resource mservice.Type
a eapi.API
config *PAPIConfig
nconfig *NotificationConfig[*T]
}
func (a *ProtectedAPI[_]) Name() mservice.Type {
return a.resource
}
func (_ *ProtectedAPI[_]) Finish(_ context.Context) error {
return nil
}
func (a *ProtectedAPI[T]) Build() *ProtectedAPI[T] {
createHandler := a.config.CreateResolver(a.create)
if createHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Oph.AddRef("/"), api.Post, createHandler)
}
listHandler := a.config.ListResolver(a.list)
if listHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Pph.AddRef(a.Oph.AddRef("/list")), api.Get, listHandler)
}
getHandler := a.config.GetResolver(a.get)
if getHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef("/"), api.Get, getHandler)
}
updateHandler := a.config.UpdateResolver(a.update)
if updateHandler != nil {
a.a.Register().AccountHandler(a.Name(), "/", api.Put, updateHandler)
}
deleteHandler := a.config.DeleteResolver(a.delete)
if deleteHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef("/"), api.Delete, deleteHandler)
}
archiveHandler := a.config.ArchiveResolver(a.archive)
if archiveHandler != nil {
a.a.Register().AccountHandler(a.Name(), a.Cph.AddRef(a.Oph.AddRef("/archive")), api.Get, archiveHandler)
}
if a.config.Reorder != nil {
a.a.Register().AccountHandler(a.Name(), "/reorder", api.Post, a.reorder)
}
if a.config.Taggable != nil {
a.a.Register().AccountHandler(a.Name(), "/tags/add", api.Put, a.addTag)
a.a.Register().AccountHandler(a.Name(), "/tags/add", api.Post, a.addTags)
a.a.Register().AccountHandler(a.Name(), "/tags", api.Delete, a.removeTag)
a.a.Register().AccountHandler(a.Name(), "/tags/all", api.Delete, a.removeAllTags)
a.a.Register().AccountHandler(a.Name(), "/tags/set", api.Post, a.setTags)
a.a.Register().AccountHandler(a.Name(), "/tags", api.Get, a.getTags)
}
return a
}
func (a *ProtectedAPI[T]) WithNotifications(factory func(template *T, actorAccountRef bson.ObjectID, t model.NotificationAction) notifications.Envelope) *ProtectedAPI[T] {
a.nconfig.WithNotifications(factory)
a.Logger.Info("Notificatons handler installed")
return a
}
// WithNoCreateNotification disables the create notification.
func (a *ProtectedAPI[T]) WithNoCreateNotification() *ProtectedAPI[T] {
a.nconfig.WithNoCreateNotification()
a.Logger.Info("Object creation notificaton disabled")
return a
}
// WithNoUpdateNotification disables the update notification.
func (a *ProtectedAPI[T]) WithNoUpdateNotification() *ProtectedAPI[T] {
a.nconfig.WithNoUpdateNotification()
a.Logger.Info("Object update notificaton disabled")
return a
}
// WithNoDeleteNotification disables the delete notification.
func (a *ProtectedAPI[T]) WithNoDeleteNotification() *ProtectedAPI[T] {
a.nconfig.WithNoDeleteNotification()
a.Logger.Info("Object deletion notificaton disabled")
return a
}
func (a *ProtectedAPI[T]) WithNoCreate() *ProtectedAPI[T] {
a.config.WithNoCreate()
a.Logger.Info("Create handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithCreateHandler(handler)
a.Logger.Info("Create handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoList() *ProtectedAPI[T] {
a.config.WithNoList()
a.Logger.Info("List handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithListHandler(handler)
a.Logger.Info("List handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoGet() *ProtectedAPI[T] {
a.config.WithNoGet()
a.Logger.Info("Get handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithGetHandler(handler)
a.Logger.Info("Get handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithReorderHandler(reorder ReorderConfig) *ProtectedAPI[T] {
a.config.WithReorderHandler(reorder)
a.Logger.Info("Reorder handler installed")
return a
}
func (a *ProtectedAPI[T]) WithTaggableHandler(taggable TaggableConfig) *ProtectedAPI[T] {
a.config.WithTaggableHandler(taggable)
a.Logger.Info("Taggable handlers installed")
return a
}
func (a *ProtectedAPI[T]) WithNoUpdate() *ProtectedAPI[T] {
a.config.WithNoUpdate()
a.Logger.Info("Update handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithUpdateHandler(handler)
a.Logger.Info("Update handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoDelete() *ProtectedAPI[T] {
a.config.WithNoDelete()
a.Logger.Info("Delete handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithDeleteHandler(handler)
a.Logger.Info("Delete handler overriden")
return a
}
func CreateAPI[T any](a eapi.API, dbFactory func() (ProtectedDB[T], error), parent, resource mservice.Type) (*ProtectedAPI[T], error) {
p := &ProtectedAPI[T]{
Logger: a.Logger().Named(resource),
Oph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref when
Pph: mutil.CreatePH(parent),
resource: resource,
Cph: mutil.CreatePH(resource),
a: a,
config: NewConfig(),
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
}
var err error
if p.DB, err = dbFactory(); err != nil {
p.Logger.Error("Failed to create protected database", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,122 @@
package papitemplate
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) addTag(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing add tag request...")
req, err := a.config.Taggable.AddTagReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode add tag request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Adding tag to object", mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
if err := a.config.Taggable.DB.AddTag(r.Context(), account.ID, req.ObjectRef, req.TagRef); err != nil {
a.Logger.Warn("Failed to add tag to object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Add tag request processing complete")
return response.Success(a.Logger)
}
func (a *ProtectedAPI[T]) addTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing add tags request...")
req, err := a.config.Taggable.AddTagsReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode add tags request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Adding tags to object", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("tag_count", len(req.TagRefs)))
if err := a.config.Taggable.DB.AddTags(r.Context(), account.ID, req.ObjectRef, req.TagRefs); err != nil {
a.Logger.Warn("Failed to add tags to object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Add tags request processing complete")
return response.Success(a.Logger)
}
func (a *ProtectedAPI[T]) removeTag(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing remove tag request...")
req, err := a.config.Taggable.RemoveTagReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode remove tag request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Removing tag from object", mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
if err := a.config.Taggable.DB.RemoveTag(r.Context(), account.ID, req.ObjectRef, req.TagRef); err != nil {
a.Logger.Warn("Failed to remove tag from object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef), mzap.ObjRef("tag_ref", req.TagRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Remove tag request processing complete")
return response.Success(a.Logger)
}
func (a *ProtectedAPI[T]) removeAllTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing remove all tags request...")
req, err := a.config.Taggable.RemoveAllTagsReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode remove all tags request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Removing all tags from object", mzap.ObjRef("object_ref", req.ObjectRef))
if err := a.config.Taggable.DB.RemoveAllTags(r.Context(), account.ID, req.ObjectRef); err != nil {
a.Logger.Warn("Failed to remove all tags from object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Remove all tags request processing complete")
return response.Success(a.Logger)
}
func (a *ProtectedAPI[T]) setTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing set tags request...")
req, err := a.config.Taggable.SetTagsReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode set tags request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Setting tags for object", mzap.ObjRef("object_ref", req.ObjectRef), zap.Int("tag_count", len(req.TagRefs)))
if err := a.config.Taggable.DB.SetTags(r.Context(), account.ID, req.ObjectRef, req.TagRefs); err != nil {
a.Logger.Warn("Failed to set tags for object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Set tags request processing complete")
return response.Success(a.Logger)
}
func (a *ProtectedAPI[T]) getTags(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
a.Logger.Debug("Processing get tags request...")
req, err := a.config.Taggable.GetTagsReqProcessor(r)
if err != nil {
a.Logger.Warn("Failed to decode get tags request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
a.Logger.Debug("Getting tags for object", mzap.ObjRef("object_ref", req.ObjectRef))
tagRefs, err := a.config.Taggable.DB.GetTags(r.Context(), account.ID, req.ObjectRef)
if err != nil {
a.Logger.Warn("Failed to get tags for object", zap.Error(err), mzap.ObjRef("object_ref", req.ObjectRef))
return response.Auto(a.Logger, a.Name(), err)
}
a.Logger.Debug("Get tags request processing complete", zap.Int("tag_count", len(tagRefs)))
return response.Ok(a.Logger, map[string]interface{}{
"tagRefs": tagRefs,
})
}

View File

@@ -0,0 +1,80 @@
package papitemplate
import (
"context"
"encoding/json"
"net/http"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
)
type (
TaggableSingleRequestProcessor func(r *http.Request) (*srequest.TaggableSingle, error)
TaggableMultipleRequestProcessor func(r *http.Request) (*srequest.TaggableMultiple, error)
TaggableObjectRequestProcessor func(r *http.Request) (*srequest.TaggableObject, error)
)
// TaggableDB interface defines the required methods for tag operations
type TaggableDB interface {
AddTag(ctx context.Context, accountRef, objectRef, tagRef bson.ObjectID) error
AddTags(ctx context.Context, accountRef, objectRef bson.ObjectID, tagRefs []bson.ObjectID) error
RemoveTag(ctx context.Context, accountRef, objectRef, tagRef bson.ObjectID) error
RemoveAllTags(ctx context.Context, accountRef, objectRef bson.ObjectID) error
SetTags(ctx context.Context, accountRef, objectRef bson.ObjectID, tagRefs []bson.ObjectID) error
GetTags(ctx context.Context, accountRef, objectRef bson.ObjectID) ([]bson.ObjectID, error)
}
type TaggableConfig struct {
DB TaggableDB
AddTagReqProcessor TaggableSingleRequestProcessor
AddTagsReqProcessor TaggableMultipleRequestProcessor
RemoveTagReqProcessor TaggableSingleRequestProcessor
RemoveAllTagsReqProcessor TaggableObjectRequestProcessor
SetTagsReqProcessor TaggableMultipleRequestProcessor
GetTagsReqProcessor TaggableObjectRequestProcessor
}
func (cfg *PAPIConfig) WithTaggableHandler(taggable TaggableConfig) *PAPIConfig {
cfg.Taggable = &taggable
if cfg.Taggable.AddTagReqProcessor == nil {
cfg.Taggable.AddTagReqProcessor = defaultTaggableSingleRequestProcessor
}
if cfg.Taggable.AddTagsReqProcessor == nil {
cfg.Taggable.AddTagsReqProcessor = defaultTaggableMultipleRequestProcessor
}
if cfg.Taggable.RemoveTagReqProcessor == nil {
cfg.Taggable.RemoveTagReqProcessor = defaultTaggableSingleRequestProcessor
}
if cfg.Taggable.RemoveAllTagsReqProcessor == nil {
cfg.Taggable.RemoveAllTagsReqProcessor = defaultTaggableObjectRequestProcessor
}
if cfg.Taggable.SetTagsReqProcessor == nil {
cfg.Taggable.SetTagsReqProcessor = defaultTaggableMultipleRequestProcessor
}
return cfg
}
func defaultTaggableSingleRequestProcessor(r *http.Request) (*srequest.TaggableSingle, error) {
var req srequest.TaggableSingle
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
return &req, nil
}
func defaultTaggableMultipleRequestProcessor(r *http.Request) (*srequest.TaggableMultiple, error) {
var req srequest.TaggableMultiple
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
return &req, nil
}
func defaultTaggableObjectRequestProcessor(r *http.Request) (*srequest.TaggableObject, error) {
var req srequest.TaggableObject
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return nil, err
}
return &req, nil
}

View File

@@ -0,0 +1,31 @@
package papitemplate
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *ProtectedAPI[T]) update(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
var object T
if err := json.NewDecoder(r.Body).Decode(&object); err != nil {
a.Logger.Warn("Failed to decode object when updating settings", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.Logger, a.Name(), err)
}
if err := a.DB.Update(r.Context(), *account.GetID(), &object); err != nil {
a.Logger.Warn("Error updating object", zap.Error(err), mzap.StorableRef(account))
return response.Auto(a.Logger, a.Name(), err)
}
if err := a.nconfig.UpdateNotification(&object, *account.GetID()); err != nil {
a.Logger.Warn("Failed to send creation notification", zap.Error(err))
}
return a.Object(&object, accessToken)
}

View File

@@ -0,0 +1,25 @@
package paymentapiimp
import (
"net"
"strings"
"github.com/tech/sendico/server/interface/api/srequest"
)
func applyCustomerIP(intent *srequest.PaymentIntent, remoteAddr string) {
if intent == nil {
return
}
ip := strings.TrimSpace(remoteAddr)
if ip == "" {
return
}
if host, _, err := net.SplitHostPort(ip); err == nil && host != "" {
ip = host
}
if intent.Customer == nil {
intent.Customer = &srequest.Customer{}
}
intent.Customer.IP = strings.TrimSpace(ip)
}

View File

@@ -0,0 +1,95 @@
package paymentapiimp
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
me "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
const discoveryLookupTimeout = 3 * time.Second
func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
if a.discovery == nil {
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery client is not configured"))
}
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for discovery registry", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when listing discovery registry", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
reqCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
defer cancel()
payload, err := a.discovery.Lookup(reqCtx)
if err != nil {
a.logger.Warn("Failed to fetch discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
return response.Ok(a.logger, payload)
}
func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
if a.refreshConsumer == nil {
return response.Internal(a.logger, a.Name(), merrors.Internal("discovery refresh consumer is not configured"))
}
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for discovery refresh", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when listing discovery refresh", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
a.refreshMu.RLock()
payload := a.refreshEvent
a.refreshMu.RUnlock()
return response.Ok(a.logger, payload)
}
func (a *PaymentAPI) handleRefreshEvent(_ context.Context, env me.Envelope) error {
var payload discovery.RefreshEvent
if err := json.Unmarshal(env.GetData(), &payload); err != nil {
a.logger.Warn("Failed to decode discovery refresh payload", zap.Error(err))
return err
}
a.refreshMu.Lock()
a.refreshEvent = &payload
a.refreshMu.Unlock()
return nil
}

View File

@@ -0,0 +1,172 @@
package paymentapiimp
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
documentsv1 "github.com/tech/sendico/pkg/proto/billing/documents/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
const (
documentsServiceName = "BILLING_DOCUMENTS"
documentsOperationGet = discovery.OperationDocumentsGet
documentsDialTimeout = 5 * time.Second
documentsCallTimeout = 10 * time.Second
)
func (a *PaymentAPI) getActDocument(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for document request", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when downloading act", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
paymentRef := strings.TrimSpace(r.URL.Query().Get("payment_ref"))
if paymentRef == "" {
paymentRef = strings.TrimSpace(r.URL.Query().Get("paymentRef"))
}
if paymentRef == "" {
return response.BadRequest(a.logger, a.Name(), "missing_parameter", "payment_ref is required")
}
if a.discovery == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "discovery client is not configured")
}
lookupCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout)
defer cancel()
lookupResp, err := a.discovery.Lookup(lookupCtx)
if err != nil {
a.logger.Warn("Failed to lookup discovery registry", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
service := findDocumentsService(lookupResp.Services)
if service == nil {
return response.Error(a.logger, a.Name(), http.StatusServiceUnavailable, "service_unavailable", "billing documents service unavailable")
}
docResp, err := a.fetchActDocument(ctx, service.InvokeURI, paymentRef)
if err != nil {
a.logger.Warn("Failed to fetch act document", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return documentErrorResponse(a.logger, a.Name(), err)
}
if len(docResp.GetContent()) == 0 {
return response.Error(a.logger, a.Name(), http.StatusInternalServerError, "empty_document", "document service returned empty payload")
}
filename := strings.TrimSpace(docResp.GetFilename())
if filename == "" {
filename = fmt.Sprintf("act_%s.pdf", paymentRef)
}
mimeType := strings.TrimSpace(docResp.GetMimeType())
if mimeType == "" {
mimeType = "application/pdf"
}
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
w.WriteHeader(http.StatusOK)
if _, writeErr := w.Write(docResp.GetContent()); writeErr != nil {
a.logger.Warn("Failed to write document response", zap.Error(writeErr))
}
}
}
func (a *PaymentAPI) fetchActDocument(ctx context.Context, invokeURI, paymentRef string) (*documentsv1.GetDocumentResponse, error) {
dialCtx, cancel := context.WithTimeout(ctx, documentsDialTimeout)
defer cancel()
conn, err := grpc.DialContext(dialCtx, invokeURI, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, merrors.InternalWrap(err, "dial billing documents")
}
defer conn.Close()
client := documentsv1.NewDocumentServiceClient(conn)
callCtx, callCancel := context.WithTimeout(ctx, documentsCallTimeout)
defer callCancel()
return client.GetDocument(callCtx, &documentsv1.GetDocumentRequest{
PaymentRef: paymentRef,
Type: documentsv1.DocumentType_DOCUMENT_TYPE_ACT,
})
}
func findDocumentsService(services []discovery.ServiceSummary) *discovery.ServiceSummary {
for _, svc := range services {
if !strings.EqualFold(svc.Service, documentsServiceName) {
continue
}
if !svc.Healthy || strings.TrimSpace(svc.InvokeURI) == "" {
continue
}
if len(svc.Ops) == 0 || hasOperation(svc.Ops, documentsOperationGet) {
return &svc
}
}
return nil
}
func hasOperation(ops []string, target string) bool {
for _, op := range ops {
if strings.EqualFold(strings.TrimSpace(op), target) {
return true
}
}
return false
}
func documentErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
statusErr, ok := status.FromError(err)
if !ok {
return response.Internal(logger, source, err)
}
switch statusErr.Code() {
case codes.InvalidArgument:
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
case codes.NotFound:
return response.NotFound(logger, source, statusErr.Message())
case codes.Unimplemented:
return response.NotImplemented(logger, source, statusErr.Message())
case codes.FailedPrecondition:
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
case codes.Unavailable:
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
default:
return response.Internal(logger, source, err)
}
}

View File

@@ -0,0 +1,41 @@
package paymentapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
statusErr, ok := status.FromError(err)
if !ok {
return response.Internal(logger, source, err)
}
switch statusErr.Code() {
case codes.InvalidArgument:
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
case codes.NotFound:
return response.NotFound(logger, source, statusErr.Message())
case codes.PermissionDenied:
return response.AccessDenied(logger, source, statusErr.Message())
case codes.Unauthenticated:
return response.Unauthorized(logger, source, statusErr.Message())
case codes.AlreadyExists, codes.Aborted:
return response.DataConflict(logger, source, statusErr.Message())
case codes.Unimplemented:
return response.NotImplemented(logger, source, statusErr.Message())
case codes.FailedPrecondition:
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
case codes.DeadlineExceeded:
return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message())
case codes.Unavailable:
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
default:
return response.Internal(logger, source, err)
}
}

View File

@@ -0,0 +1,199 @@
package paymentapiimp
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
const maxInt32 = int64(1<<31 - 1)
func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments read permission denied")
}
req := &orchestrationv2.ListPaymentsRequest{Meta: requestMeta(orgRef.Hex(), "")}
if page, err := listPaymentsPage(r); err != nil {
return response.Auto(a.logger, a.Name(), err)
} else if page != nil {
req.Page = page
}
query := r.URL.Query()
if quotationRef := firstNonEmpty(query.Get("quotation_ref"), query.Get("quote_ref")); quotationRef != "" {
req.QuotationRef = quotationRef
}
createdFrom, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_from"), query.Get("createdFrom")), "created_from")
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
if createdFrom != nil {
req.CreatedFrom = createdFrom
}
createdTo, err := parseRFC3339Timestamp(firstNonEmpty(query.Get("created_to"), query.Get("createdTo")), "created_to")
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
if createdTo != nil {
req.CreatedTo = createdTo
}
if req.GetCreatedFrom() != nil && req.GetCreatedTo() != nil {
if !req.GetCreatedTo().AsTime().After(req.GetCreatedFrom().AsTime()) {
return response.Auto(a.logger, a.Name(), merrors.InvalidArgument("created_to must be after created_from", "created_to"))
}
}
if states, err := parsePaymentStateFilters(r); err != nil {
return response.Auto(a.logger, a.Name(), err)
} else if len(states) > 0 {
req.States = states
}
resp, err := a.execution.ListPayments(ctx, req)
if err != nil {
a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.PaymentsListResponse(a.logger, resp, token)
}
func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) {
query := r.URL.Query()
cursor := strings.TrimSpace(query.Get("cursor"))
limitRaw := strings.TrimSpace(query.Get("limit"))
var limit int64
hasLimit := false
if limitRaw != "" {
parsed, err := strconv.ParseInt(limitRaw, 10, 32)
if err != nil {
return nil, merrors.InvalidArgument("invalid limit", "limit")
}
limit = parsed
hasLimit = true
}
if cursor == "" && !hasLimit {
return nil, nil
}
page := &paginationv1.CursorPageRequest{
Cursor: cursor,
}
if hasLimit {
if limit < 0 {
limit = 0
} else if limit > maxInt32 {
limit = maxInt32
}
page.Limit = int32(limit)
}
return page, nil
}
func parsePaymentStateFilters(r *http.Request) ([]orchestrationv2.OrchestrationState, error) {
query := r.URL.Query()
values := append([]string{}, query["state"]...)
values = append(values, query["states"]...)
values = append(values, query["filter_states"]...)
if len(values) == 0 {
return nil, nil
}
states := make([]orchestrationv2.OrchestrationState, 0, len(values))
for _, raw := range values {
for _, part := range strings.Split(raw, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
state, ok := orchestrationStateFromString(trimmed)
if !ok {
return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state")
}
states = append(states, state)
}
}
if len(states) == 0 {
return nil, nil
}
return states, nil
}
func orchestrationStateFromString(value string) (orchestrationv2.OrchestrationState, bool) {
upper := strings.ToUpper(strings.TrimSpace(value))
if upper == "" {
return 0, false
}
switch upper {
case "PAYMENT_STATE_ACCEPTED", "ACCEPTED":
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_CREATED, true
case "PAYMENT_STATE_FUNDS_RESERVED", "FUNDS_RESERVED", "PAYMENT_STATE_SUBMITTED", "SUBMITTED":
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_EXECUTING, true
case "PAYMENT_STATE_SETTLED", "SETTLED":
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_SETTLED, true
case "PAYMENT_STATE_FAILED", "FAILED", "PAYMENT_STATE_CANCELLED", "CANCELLED":
return orchestrationv2.OrchestrationState_ORCHESTRATION_STATE_FAILED, true
}
if !strings.HasPrefix(upper, "ORCHESTRATION_STATE_") {
upper = "ORCHESTRATION_STATE_" + upper
}
enumValue, ok := orchestrationv2.OrchestrationState_value[upper]
if !ok {
return 0, false
}
return orchestrationv2.OrchestrationState(enumValue), true
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}
func parseRFC3339Timestamp(raw string, field string) (*timestamppb.Timestamp, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, trimmed)
if err != nil {
return nil, merrors.InvalidArgument("invalid "+field+", expected RFC3339", field)
}
return timestamppb.New(parsed), nil
}

View File

@@ -0,0 +1,319 @@
package paymentapiimp
import (
"strconv"
"strings"
"github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
payecon "github.com/tech/sendico/pkg/payments/economics"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest"
"go.mongodb.org/mongo-driver/v2/bson"
)
func mapQuoteIntent(intent *srequest.PaymentIntent) (*quotationv2.QuoteIntent, error) {
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
if err := validatePaymentKind(intent.Kind); err != nil {
return nil, err
}
settlementMode, err := mapSettlementMode(intent.SettlementMode)
if err != nil {
return nil, err
}
feeTreatment, err := mapFeeTreatment(intent.FeeTreatment)
if err != nil {
return nil, err
}
resolvedSettlementMode, resolvedFeeTreatment, err := payecon.ResolveSettlementAndFee(settlementMode, feeTreatment)
if err != nil {
return nil, err
}
settlementCurrency := resolveSettlementCurrency(intent)
if settlementCurrency == "" {
return nil, merrors.InvalidArgument("unable to derive settlement currency from intent")
}
source, err := mapQuoteEndpoint(intent.Source, "intent.source")
if err != nil {
return nil, err
}
destination, err := mapQuoteEndpoint(intent.Destination, "intent.destination")
if err != nil {
return nil, err
}
quoteIntent := &quotationv2.QuoteIntent{
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
SettlementMode: resolvedSettlementMode,
FeeTreatment: resolvedFeeTreatment,
SettlementCurrency: settlementCurrency,
FxSide: mapFXSide(intent),
}
if comment := strings.TrimSpace(intent.Attributes["comment"]); comment != "" {
quoteIntent.Comment = comment
}
return quoteIntent, nil
}
func mapFXSide(intent *srequest.PaymentIntent) fxv1.Side {
if intent == nil || intent.FX == nil {
return fxv1.Side_SIDE_UNSPECIFIED
}
switch strings.TrimSpace(string(intent.FX.Side)) {
case string(srequest.FXSideBuyBaseSellQuote):
return fxv1.Side_BUY_BASE_SELL_QUOTE
case string(srequest.FXSideSellBaseBuyQuote):
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func validatePaymentKind(kind srequest.PaymentKind) error {
switch strings.TrimSpace(string(kind)) {
case string(srequest.PaymentKindPayout), string(srequest.PaymentKindInternalTransfer), string(srequest.PaymentKindFxConversion):
return nil
default:
return merrors.InvalidArgument("unsupported payment kind: " + string(kind))
}
}
func resolveSettlementCurrency(intent *srequest.PaymentIntent) string {
if intent == nil {
return ""
}
fx := intent.FX
if fx != nil && fx.Pair != nil {
base := strings.TrimSpace(fx.Pair.Base)
quote := strings.TrimSpace(fx.Pair.Quote)
switch strings.TrimSpace(string(fx.Side)) {
case string(srequest.FXSideBuyBaseSellQuote):
if base != "" {
return base
}
case string(srequest.FXSideSellBaseBuyQuote):
if quote != "" {
return quote
}
}
}
if intent.Amount != nil {
return strings.TrimSpace(intent.Amount.Currency)
}
return ""
}
func mapQuoteEndpoint(endpoint *srequest.Endpoint, field string) (*endpointv1.PaymentEndpoint, error) {
if endpoint == nil {
return nil, merrors.InvalidArgument(field + " is required")
}
switch endpoint.Type {
case srequest.EndpointTypeLedger:
payload, err := endpoint.DecodeLedger()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method := &ledgerMethodData{
LedgerAccountRef: strings.TrimSpace(payload.LedgerAccountRef),
ContraLedgerAccountRef: strings.TrimSpace(payload.ContraLedgerAccountRef),
}
if method.LedgerAccountRef == "" {
return nil, merrors.InvalidArgument(field + ".ledger_account_ref is required")
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, method)
case srequest.EndpointTypeManagedWallet:
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.ManagedWalletRef)}
if method.WalletID == "" {
return nil, merrors.InvalidArgument(field + ".managed_wallet_ref is required")
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
case srequest.EndpointTypeWallet:
payload, err := endpoint.DecodeWallet()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method := &pkgmodel.WalletPaymentData{WalletID: strings.TrimSpace(payload.WalletID)}
if method.WalletID == "" {
return nil, merrors.InvalidArgument(field + ".walletId is required")
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, method)
case srequest.EndpointTypeExternalChain:
payload, err := endpoint.DecodeExternalChain()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method, mapErr := mapExternalChainMethod(payload, field)
if mapErr != nil {
return nil, mapErr
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, method)
case srequest.EndpointTypeCard:
payload, err := endpoint.DecodeCard()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method := &pkgmodel.CardPaymentData{
Pan: strings.TrimSpace(payload.Pan),
FirstName: strings.TrimSpace(payload.FirstName),
LastName: strings.TrimSpace(payload.LastName),
ExpMonth: uint32ToString(payload.ExpMonth),
ExpYear: uint32ToString(payload.ExpYear),
Country: strings.TrimSpace(payload.Country),
}
if method.Pan == "" {
return nil, merrors.InvalidArgument(field + ".pan is required")
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, method)
case srequest.EndpointTypeCardToken:
payload, err := endpoint.DecodeCardToken()
if err != nil {
return nil, merrors.InvalidArgument(field + ": " + err.Error())
}
method := &pkgmodel.TokenPaymentData{
Token: strings.TrimSpace(payload.Token),
Last4: strings.TrimSpace(payload.MaskedPan),
}
if method.Token == "" {
return nil, merrors.InvalidArgument(field + ".token is required")
}
return endpointFromMethod(endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, method)
case "":
return nil, merrors.InvalidArgument(field + " endpoint type is required")
default:
return nil, merrors.InvalidArgument(field + " endpoint type is unsupported in v2: " + string(endpoint.Type))
}
}
func mapExternalChainMethod(payload srequest.ExternalChainEndpoint, field string) (*pkgmodel.CryptoAddressPaymentData, error) {
address := strings.TrimSpace(payload.Address)
if address == "" {
return nil, merrors.InvalidArgument(field + ".address is required")
}
if payload.Asset == nil {
return nil, merrors.InvalidArgument(field + ".asset is required")
}
token := strings.ToUpper(strings.TrimSpace(payload.Asset.TokenSymbol))
if token == "" {
return nil, merrors.InvalidArgument(field + ".asset.token_symbol is required")
}
if _, err := mapChainNetwork(payload.Asset.Chain); err != nil {
return nil, merrors.InvalidArgument(field + ".asset.chain: " + err.Error())
}
result := &pkgmodel.CryptoAddressPaymentData{
Currency: pkgmodel.Currency(token),
Address: address,
Network: strings.ToUpper(strings.TrimSpace(string(payload.Asset.Chain))),
}
if memo := strings.TrimSpace(payload.Memo); memo != "" {
result.DestinationTag = &memo
}
return result, nil
}
func endpointFromMethod(methodType endpointv1.PaymentMethodType, data any) (*endpointv1.PaymentEndpoint, error) {
raw, err := bson.Marshal(data)
if err != nil {
return nil, merrors.InternalWrap(err, "failed to encode payment method data")
}
method := &endpointv1.PaymentMethod{
Type: methodType,
Data: raw,
}
return &endpointv1.PaymentEndpoint{
Source: &endpointv1.PaymentEndpoint_PaymentMethod{
PaymentMethod: method,
},
}, nil
}
func mapMoney(m *paymenttypes.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{
Amount: m.Amount,
Currency: m.Currency,
}
}
func mapSettlementMode(mode srequest.SettlementMode) (paymentv1.SettlementMode, error) {
switch strings.TrimSpace(string(mode)) {
case "", string(srequest.SettlementModeUnspecified):
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, nil
case string(srequest.SettlementModeFixSource):
return paymentv1.SettlementMode_SETTLEMENT_FIX_SOURCE, nil
case string(srequest.SettlementModeFixReceived):
return paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, nil
default:
return paymentv1.SettlementMode_SETTLEMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
}
}
func mapFeeTreatment(treatment srequest.FeeTreatment) (quotationv2.FeeTreatment, error) {
switch strings.TrimSpace(string(treatment)) {
case "", string(srequest.FeeTreatmentUnspecified):
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, nil
case string(srequest.FeeTreatmentAddToSource):
return quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE, nil
case string(srequest.FeeTreatmentDeductFromDestination):
return quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION, nil
default:
return quotationv2.FeeTreatment_FEE_TREATMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported fee treatment: " + string(treatment))
}
}
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
switch strings.TrimSpace(string(chain)) {
case "", string(srequest.ChainNetworkUnspecified):
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
case string(srequest.ChainNetworkEthereumMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case string(srequest.ChainNetworkArbitrumOne):
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case string(srequest.ChainNetworkTronMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case string(srequest.ChainNetworkTronNile):
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
}
}
func uint32ToString(v uint32) string {
if v == 0 {
return ""
}
return strconv.FormatUint(uint64(v), 10)
}
type ledgerMethodData struct {
LedgerAccountRef string `bson:"ledgerAccountRef"`
ContraLedgerAccountRef string `bson:"contraLedgerAccountRef,omitempty"`
}

View File

@@ -0,0 +1,255 @@
package paymentapiimp
import (
"testing"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest"
)
func TestMapQuoteIntent_PropagatesFeeTreatment(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixReceived,
FeeTreatment: srequest.FeeTreatmentDeductFromDestination,
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("mapQuoteIntent returned error: %v", err)
}
if got == nil {
t.Fatalf("expected mapped quote intent")
}
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_DEDUCT_FROM_DESTINATION {
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
}
}
func TestMapQuoteIntent_InvalidFeeTreatmentFails(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatment("wrong_value"),
}
if _, err := mapQuoteIntent(intent); err == nil {
t.Fatalf("expected error for invalid fee treatment")
}
}
func TestMapQuoteIntent_AcceptsIndependentSettlementAndFeeTreatment(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixReceived,
FeeTreatment: srequest.FeeTreatmentAddToSource,
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("mapQuoteIntent returned error: %v", err)
}
if got.GetSettlementMode() != paymentv1.SettlementMode_SETTLEMENT_FIX_RECEIVED {
t.Fatalf("unexpected settlement mode: got=%s", got.GetSettlementMode().String())
}
if got.GetFeeTreatment() != quotationv2.FeeTreatment_FEE_TREATMENT_ADD_TO_SOURCE {
t.Fatalf("unexpected fee treatment: got=%s", got.GetFeeTreatment().String())
}
}
func TestMapQuoteIntent_DerivesSettlementCurrencyFromAmountWithoutFX(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetSettlementCurrency() != "USDT" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
}
func TestMapQuoteIntent_DerivesSettlementCurrencyFromFX(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
FX: &srequest.FXIntent{
Pair: &srequest.CurrencyPair{
Base: "USDT",
Quote: "RUB",
},
Side: srequest.FXSideSellBaseBuyQuote,
},
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetSettlementCurrency() != "RUB" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
if got.GetFxSide() != fxv1.Side_SELL_BASE_BUY_QUOTE {
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
}
}
func TestMapQuoteIntent_PropagatesFXSideBuyBaseSellQuote(t *testing.T) {
source, err := srequest.NewManagedWalletEndpointDTO(srequest.ManagedWalletEndpoint{
ManagedWalletRef: "wallet-source-1",
}, nil)
if err != nil {
t.Fatalf("failed to build source endpoint: %v", err)
}
destination, err := srequest.NewCardEndpointDTO(srequest.CardEndpoint{
Pan: "2200700142860161",
FirstName: "John",
LastName: "Doe",
ExpMonth: 3,
ExpYear: 2030,
}, nil)
if err != nil {
t.Fatalf("failed to build destination endpoint: %v", err)
}
intent := &srequest.PaymentIntent{
Kind: srequest.PaymentKindPayout,
Source: &source,
Destination: &destination,
Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"},
SettlementMode: srequest.SettlementModeFixSource,
FeeTreatment: srequest.FeeTreatmentAddToSource,
FX: &srequest.FXIntent{
Pair: &srequest.CurrencyPair{
Base: "RUB",
Quote: "USDT",
},
Side: srequest.FXSideBuyBaseSellQuote,
},
}
got, err := mapQuoteIntent(intent)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.GetFxSide() != fxv1.Side_BUY_BASE_SELL_QUOTE {
t.Fatalf("unexpected fx_side: got=%s", got.GetFxSide().String())
}
if got.GetSettlementCurrency() != "RUB" {
t.Fatalf("unexpected settlement currency: got=%q", got.GetSettlementCurrency())
}
}

View File

@@ -0,0 +1,144 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// shared initiation pipeline
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when initiating payment", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeInitiatePayload(r)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if expectQuote {
if payload.QuoteRef == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quoteRef is required"))
}
if payload.Intent != nil {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be combined with intent"))
}
} else {
if payload.Intent == nil {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("intent is required"))
}
if payload.QuoteRef != "" {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be used when intent is provided"))
}
}
quotationRef := strings.TrimSpace(payload.QuoteRef)
if metadataValue(payload.Metadata, "intent_ref") != "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("metadata.intent_ref is no longer supported", "metadata.intent_ref"))
}
if payload.Intent != nil {
applyCustomerIP(payload.Intent, r.RemoteAddr)
intent, err := mapQuoteIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
quoteResp, qErr := a.quotation.QuotePayment(ctx, &quotationv2.QuotePaymentRequest{
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
Intent: intent,
InitiatorRef: initiatorRef(account),
})
if qErr != nil {
a.logger.Warn("Failed to quote payment before execution", zap.Error(qErr), mzap.ObjRef("organization_ref", orgRef))
return grpcErrorResponse(a.logger, a.Name(), qErr)
}
quotationRef = strings.TrimSpace(quoteResp.GetQuote().GetQuoteRef())
if quotationRef == "" {
return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref"))
}
}
req := &orchestrationv2.ExecutePaymentRequest{
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
QuotationRef: quotationRef,
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
}
resp, err := a.execution.ExecutePayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to initiate payment", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.PaymentResponse(a.logger, resp.GetPayment(), token)
}
func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
defer r.Body.Close()
payload := &srequest.InitiatePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}
func requestMeta(organizationRef string, idempotencyKey string) *sharedv1.RequestMeta {
return &sharedv1.RequestMeta{
OrganizationRef: strings.TrimSpace(organizationRef),
Trace: &tracev1.TraceContext{
IdempotencyKey: strings.TrimSpace(idempotencyKey),
},
}
}
func metadataValue(meta map[string]string, key string) string {
if len(meta) == 0 {
return ""
}
return strings.TrimSpace(meta[strings.TrimSpace(key)])
}
func initiatorRef(account *model.Account) string {
if account == nil {
return ""
}
if account.ID != bson.NilObjectID {
return account.ID.Hex()
}
return strings.TrimSpace(account.Login)
}

View File

@@ -0,0 +1,65 @@
package paymentapiimp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
rr := invokeInitiateByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeReqs), 1; got != want {
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
}
if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
}
}
func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"intent_ref":"legacy-intent"}}`
rr := invokeInitiateByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusBadRequest; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func invokeInitiateByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/by-quote", bytes.NewBufferString(body))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler := api.initiateByQuote(req, &model.Account{}, &sresponse.TokenData{
Token: "token",
Expiration: time.Now().UTC().Add(time.Hour),
})
handler.ServeHTTP(rr, req)
return rr
}

View File

@@ -0,0 +1,80 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for batch payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when initiating batch payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeInitiatePaymentsPayload(r)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
idempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
quotationRef := strings.TrimSpace(payload.QuoteRef)
req := &orchestrationv2.ExecuteBatchPaymentRequest{
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
QuotationRef: quotationRef,
ClientPaymentRef: clientPaymentRef,
}
resp, err := a.execution.ExecuteBatchPayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return grpcErrorResponse(a.logger, a.Name(), err)
}
payments := make([]*orchestrationv2.Payment, 0)
if resp != nil {
payments = append(payments, resp.GetPayments()...)
}
return sresponse.PaymentsResponse(a.logger, payments, token)
}
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
defer r.Body.Close()
payload := &srequest.InitiatePayments{}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -0,0 +1,172 @@
package paymentapiimp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1"}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeBatchReqs), 1; got != want {
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
if got, want := exec.executeBatchReqs[0].GetQuotationRef(), "quote-1"; got != want {
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
}
if got, want := exec.executeBatchReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-batch"; got != want {
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
}
}
func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeBatchReqs), 1; got != want {
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
}
if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRef":"intent-legacy"}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusBadRequest; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefsField(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusBadRequest; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func newBatchAPI(exec executionClient) *PaymentAPI {
return &PaymentAPI{
logger: mlogger.Logger(zap.NewNop()),
execution: exec,
enf: fakeEnforcerForBatch{allowed: true},
oph: mutil.CreatePH(mservice.Organizations),
permissionRef: bson.NewObjectID(),
}
}
func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{
Token: "token",
Expiration: time.Now().UTC().Add(time.Hour),
})
handler.ServeHTTP(rr, req)
return rr
}
type fakeExecutionClientForBatch struct {
executeReqs []*orchestrationv2.ExecutePaymentRequest
executeBatchReqs []*orchestrationv2.ExecuteBatchPaymentRequest
}
func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
f.executeReqs = append(f.executeReqs, req)
return &orchestrationv2.ExecutePaymentResponse{
Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()},
}, nil
}
func (f *fakeExecutionClientForBatch) ExecuteBatchPayment(_ context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) {
f.executeBatchReqs = append(f.executeBatchReqs, req)
return &orchestrationv2.ExecuteBatchPaymentResponse{
Payments: []*orchestrationv2.Payment{{PaymentRef: bson.NewObjectID().Hex()}},
}, nil
}
func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
return &orchestrationv2.ListPaymentsResponse{}, nil
}
func (*fakeExecutionClientForBatch) Close() error { return nil }
type fakeEnforcerForBatch struct {
allowed bool
}
func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
return f.allowed, nil
}
func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
return nil, nil
}
func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
return nil, nil
}
func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
return nil, nil, nil
}
var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil)

View File

@@ -0,0 +1,13 @@
package paymentapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
)
// initiateImmediate runs a one-shot payment using a fresh quote.
func (a *PaymentAPI) initiateImmediate(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
return a.initiatePayment(r, account, token, false)
}

View File

@@ -0,0 +1,13 @@
package paymentapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
)
// initiateByQuote executes a payment using a previously issued quote_ref.
func (a *PaymentAPI) initiateByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
return a.initiatePayment(r, account, token, true)
}

View File

@@ -0,0 +1,153 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for quote", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when quoting payment", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeQuotePayload(r)
if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
applyCustomerIP(&payload.Intent, r.RemoteAddr)
intent, err := mapQuoteIntent(&payload.Intent)
if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
req := &quotationv2.QuotePaymentRequest{
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
IdempotencyKey: payload.IdempotencyKey,
Intent: intent,
PreviewOnly: payload.PreviewOnly,
InitiatorRef: initiatorRef(account),
}
resp, err := a.quotation.QuotePayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.PaymentQuoteResponse(a.logger, resp.GetIdempotencyKey(), resp.GetQuote(), token)
}
func (a *PaymentAPI) quotePayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for quotes", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when quoting payments", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeQuotePaymentsPayload(r)
if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
intents := make([]*quotationv2.QuoteIntent, 0, len(payload.Intents))
for i := range payload.Intents {
applyCustomerIP(&payload.Intents[i], r.RemoteAddr)
intent, err := mapQuoteIntent(&payload.Intents[i])
if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
intents = append(intents, intent)
}
req := &quotationv2.QuotePaymentsRequest{
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
IdempotencyKey: payload.IdempotencyKey,
Intents: intents,
PreviewOnly: payload.PreviewOnly,
InitiatorRef: initiatorRef(account),
}
resp, err := a.quotation.QuotePayments(ctx, req)
if err != nil {
a.logger.Warn("Failed to quote payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.PaymentQuotesResponse(a.logger, resp, token)
}
func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
defer r.Body.Close()
payload := &srequest.QuotePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}
func decodeQuotePaymentsPayload(r *http.Request) (*srequest.QuotePayments, error) {
defer r.Body.Close()
payload := &srequest.QuotePayments{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -0,0 +1,293 @@
package paymentapiimp
import (
"context"
"crypto/tls"
"fmt"
"os"
"strings"
"sync"
"time"
orchestratorclient "github.com/tech/sendico/payments/orchestrator/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
msgconsumer "github.com/tech/sendico/pkg/messaging/consumer"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
type executionClient interface {
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
Close() error
}
type quotationClient interface {
QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error)
QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error)
Close() error
}
type PaymentAPI struct {
logger mlogger.Logger
execution executionClient
quotation quotationClient
enf auth.Enforcer
oph mutil.ParamHelper
discovery *discovery.Client
refreshConsumer msg.Consumer
refreshMu sync.RWMutex
refreshEvent *discovery.RefreshEvent
permissionRef bson.ObjectID
}
func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
func (a *PaymentAPI) Finish(ctx context.Context) error {
if a.execution != nil {
if err := a.execution.Close(); err != nil {
a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err))
}
}
if a.quotation != nil {
if err := a.quotation.Close(); err != nil {
a.logger.Warn("Failed to close payment quotation client", zap.Error(err))
}
}
if a.discovery != nil {
a.discovery.Close()
}
if a.refreshConsumer != nil {
a.refreshConsumer.Close()
}
return nil
}
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p := &PaymentAPI{
logger: apiCtx.Logger().Named(mservice.Payments),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
if err != nil {
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
return nil, err
}
p.permissionRef = desc.ID
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator, apiCtx.Config().PaymentQuotation); err != nil {
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
return nil, err
}
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
}
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/multiquote"), api.Post, p.quotePayments)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/documents/act"), api.Get, p.getActDocument)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh)
return p, nil
}
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig, quoteCfg *eapi.PaymentOrchestratorConfig) error {
if cfg == nil {
return merrors.InvalidArgument("payment orchestrator configuration is not provided")
}
address, err := resolveClientAddress("payment orchestrator", cfg)
if err != nil {
return err
}
quoteAddress := address
quoteInsecure := cfg.Insecure
quoteDialTimeout := cfg.DialTimeoutSeconds
quoteCallTimeout := cfg.CallTimeoutSeconds
if quoteCfg != nil {
if addr := strings.TrimSpace(quoteCfg.Address); addr != "" {
quoteAddress = addr
} else if env := strings.TrimSpace(quoteCfg.AddressEnv); env != "" {
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
quoteAddress = resolved
}
}
quoteInsecure = quoteCfg.Insecure
quoteDialTimeout = quoteCfg.DialTimeoutSeconds
quoteCallTimeout = quoteCfg.CallTimeoutSeconds
}
clientCfg := orchestratorclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
execution, err := orchestratorclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
quotation, err := newQuotationClient(context.Background(), quotationClientConfig{
Address: quoteAddress,
DialTimeout: time.Duration(quoteDialTimeout) * time.Second,
CallTimeout: time.Duration(quoteCallTimeout) * time.Second,
Insecure: quoteInsecure,
})
if err != nil {
_ = execution.Close()
return err
}
a.execution = execution
a.quotation = quotation
return nil
}
func resolveClientAddress(service string, cfg *eapi.PaymentOrchestratorConfig) (string, error) {
if cfg == nil {
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " configuration is not provided")
}
address := strings.TrimSpace(cfg.Address)
if address != "" {
return address, nil
}
if env := strings.TrimSpace(cfg.AddressEnv); env != "" {
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
return resolved, nil
}
return "", merrors.InvalidArgument(fmt.Sprintf("%s address is not specified and address env %s is empty", strings.TrimSpace(service), env))
}
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " address is not specified")
}
type quotationClientConfig struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
Insecure bool
}
func (c *quotationClientConfig) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 3 * time.Second
}
}
type grpcQuotationClient struct {
conn *grpc.ClientConn
client quotationv2.QuotationServiceClient
callTimeout time.Duration
}
func newQuotationClient(ctx context.Context, cfg quotationClientConfig, opts ...grpc.DialOption) (quotationClient, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("payment quotation: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, opts...)
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, fmt.Sprintf("payment-quotation: dial %s", cfg.Address))
}
return &grpcQuotationClient{
conn: conn,
client: quotationv2.NewQuotationServiceClient(conn),
callTimeout: cfg.CallTimeout,
}, nil
}
func (c *grpcQuotationClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *grpcQuotationClient) QuotePayment(ctx context.Context, req *quotationv2.QuotePaymentRequest) (*quotationv2.QuotePaymentResponse, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
return c.client.QuotePayment(callCtx, req)
}
func (c *grpcQuotationClient) QuotePayments(ctx context.Context, req *quotationv2.QuotePaymentsRequest) (*quotationv2.QuotePaymentsResponse, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
return c.client.QuotePayments(callCtx, req)
}
func (c *grpcQuotationClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.callTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error {
if cfg == nil || cfg.Mw == nil {
return nil
}
msgCfg := cfg.Mw.Messaging
if msgCfg.Driver == "" {
return nil
}
broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg)
if err != nil {
return err
}
client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name()))
if err != nil {
return err
}
a.discovery = client
refreshConsumer, err := msgconsumer.NewConsumer(a.logger, broker, discovery.RefreshUIEvent())
if err != nil {
return err
}
a.refreshConsumer = refreshConsumer
go func() {
if err := refreshConsumer.ConsumeMessages(a.handleRefreshEvent); err != nil {
a.logger.Warn("Discovery refresh consumer stopped", zap.Error(err))
}
}()
return nil
}

View File

@@ -0,0 +1,621 @@
package paymethodsimp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
methodsclient "github.com/tech/sendico/payments/methods/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
archivablev1 "github.com/tech/sendico/pkg/proto/common/archivable/v1"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
oboundv1 "github.com/tech/sendico/pkg/proto/common/organization_bound/v1"
paginationv2 "github.com/tech/sendico/pkg/proto/common/pagination/v2"
pboundv1 "github.com/tech/sendico/pkg/proto/common/permission_bound/v1"
storablev1 "github.com/tech/sendico/pkg/proto/common/storable/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
methodsv1 "github.com/tech/sendico/pkg/proto/payments/methods/v1"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
type PaymentMethodsAPI struct {
logger mlogger.Logger
client methodsclient.Client
oph mutil.ParamHelper
rph mutil.ParamHelper
mph mutil.ParamHelper
}
func (a *PaymentMethodsAPI) Name() mservice.Type {
return mservice.PaymentMethods
}
func (a *PaymentMethodsAPI) Finish(_ context.Context) error {
if a.client != nil {
return a.client.Close()
}
return nil
}
func CreateAPI(apiCtx eapi.API) (*PaymentMethodsAPI, error) {
logger := apiCtx.Logger().Named(mservice.PaymentMethods)
cfg := apiCtx.Config().PaymentMethods
if cfg == nil {
return nil, merrors.InvalidArgument("payment methods configuration is not provided")
}
address, err := resolveClientAddress("payment methods", cfg)
if err != nil {
return nil, err
}
clientCfg := methodsclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := methodsclient.New(context.Background(), clientCfg)
if err != nil {
return nil, err
}
res := &PaymentMethodsAPI{
logger: logger,
client: client,
oph: mutil.CreatePH(mservice.Organizations),
rph: mutil.CreatePH(mservice.Recipients),
mph: mutil.CreatePH(mservice.PaymentMethods),
}
apiCtx.Register().AccountHandler(res.Name(), res.oph.AddRef("/"), api.Post, res.create)
apiCtx.Register().AccountHandler(res.Name(), res.rph.AddRef(res.oph.AddRef("/list")), api.Get, res.list)
apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef("/"), api.Get, res.get)
apiCtx.Register().AccountHandler(res.Name(), "/", api.Put, res.update)
apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef("/"), api.Delete, res.delete)
apiCtx.Register().AccountHandler(res.Name(), res.mph.AddRef(res.oph.AddRef("/archive")), api.Get, res.archive)
return res, nil
}
func (a *PaymentMethodsAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
payload, err := io.ReadAll(r.Body)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
pm, err := decodePaymentMethodJSON(payload)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
method, err := encodePaymentMethodProto(pm)
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
resp, err := a.client.CreatePaymentMethod(r.Context(), &methodsv1.CreatePaymentMethodRequest{
AccountRef: account.ID.Hex(),
OrganizationRef: orgRef.Hex(),
PaymentMethod: method,
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
pm, err = decodePaymentMethodRecord(resp.GetPaymentMethodRecord())
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.ObjectAuthCreated(a.logger, pm, token, a.Name())
}
func (a *PaymentMethodsAPI) list(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
recipientRef, err := a.rph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.rph.Name(), a.rph.GetID(r), err)
}
cursor, err := mutil.GetViewCursor(a.logger, r)
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
resp, err := a.client.ListPaymentMethods(r.Context(), &methodsv1.ListPaymentMethodsRequest{
AccountRef: account.ID.Hex(),
OrganizationRef: orgRef.Hex(),
RecipientRef: recipientRef.Hex(),
Cursor: toProtoCursor(cursor),
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
items, err := decodePaymentMethods(resp.GetPaymentMethods())
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.ObjectsAuth(a.logger, items, token, a.Name())
}
func (a *PaymentMethodsAPI) get(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
methodRef, err := a.mph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err)
}
resp, err := a.client.GetPaymentMethod(r.Context(), &methodsv1.GetPaymentMethodRequest{
AccountRef: account.ID.Hex(),
PaymentMethodRef: methodRef.Hex(),
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
pm, err := decodePaymentMethodRecord(resp.GetPaymentMethodRecord())
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.ObjectAuth(a.logger, pm, token, a.Name())
}
func (a *PaymentMethodsAPI) update(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
payload, err := io.ReadAll(r.Body)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
pm, err := decodePaymentMethodJSON(payload)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
record, err := encodePaymentMethodRecord(pm)
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
resp, err := a.client.UpdatePaymentMethod(r.Context(), &methodsv1.UpdatePaymentMethodRequest{
AccountRef: account.ID.Hex(),
PaymentMethodRecord: record,
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
pm, err = decodePaymentMethodRecord(resp.GetPaymentMethodRecord())
if err != nil {
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.ObjectAuth(a.logger, pm, token, a.Name())
}
func (a *PaymentMethodsAPI) delete(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
methodRef, err := a.mph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err)
}
cascade, err := mutil.GetCascadeParam(a.logger, r)
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
cascadeValue := false
if cascade != nil {
cascadeValue = *cascade
}
_, err = a.client.DeletePaymentMethod(r.Context(), &methodsv1.DeletePaymentMethodRequest{
AccountRef: account.ID.Hex(),
PaymentMethodRef: methodRef.Hex(),
Cascade: cascadeValue,
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.ObjectsAuth(a.logger, []model.PaymentMethod{}, token, a.Name())
}
func (a *PaymentMethodsAPI) archive(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
methodRef, err := a.mph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.mph.Name(), a.mph.GetID(r), err)
}
orgRef, err := a.oph.GetRef(r)
if err != nil {
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
archived, err := mutil.GetArchiveParam(a.logger, r)
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
if archived == nil {
return response.BadRequest(a.logger, a.Name(), "invalid_query_parameter", "'archived' param must be present")
}
cascade, err := mutil.GetCascadeParam(a.logger, r)
if err != nil {
return response.Auto(a.logger, a.Name(), err)
}
cascadeValue := false
if cascade != nil {
cascadeValue = *cascade
}
_, err = a.client.SetPaymentMethodArchived(r.Context(), &methodsv1.SetPaymentMethodArchivedRequest{
AccountRef: account.ID.Hex(),
OrganizationRef: orgRef.Hex(),
PaymentMethodRef: methodRef.Hex(),
Archived: *archived,
Cascade: cascadeValue,
})
if err != nil {
return grpcErrorResponse(a.logger, a.Name(), err)
}
return sresponse.ObjectsAuth(a.logger, []model.PaymentMethod{}, token, a.Name())
}
func resolveClientAddress(service string, cfg *eapi.PaymentOrchestratorConfig) (string, error) {
if cfg == nil {
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " configuration is not provided")
}
address := strings.TrimSpace(cfg.Address)
if address != "" {
return address, nil
}
if env := strings.TrimSpace(cfg.AddressEnv); env != "" {
if resolved := strings.TrimSpace(os.Getenv(env)); resolved != "" {
return resolved, nil
}
return "", merrors.InvalidArgument(service + " address is not specified and address env " + env + " is empty")
}
return "", merrors.InvalidArgument(strings.TrimSpace(service) + " address is not specified")
}
func toProtoCursor(cursor *model.ViewCursor) *paginationv2.ViewCursor {
if cursor == nil {
return nil
}
res := &paginationv2.ViewCursor{}
hasAny := false
if cursor.Limit != nil {
res.Limit = wrapperspb.Int64(*cursor.Limit)
hasAny = true
}
if cursor.Offset != nil {
res.Offset = wrapperspb.Int64(*cursor.Offset)
hasAny = true
}
if cursor.IsArchived != nil {
res.IsArchived = wrapperspb.Bool(*cursor.IsArchived)
hasAny = true
}
if !hasAny {
return nil
}
return res
}
func decodePaymentMethodJSON(payload []byte) (*model.PaymentMethod, error) {
var pm model.PaymentMethod
if err := json.Unmarshal(payload, &pm); err != nil {
return nil, err
}
return &pm, nil
}
func decodePaymentMethodRecord(record *endpointv1.PaymentMethodRecord) (*model.PaymentMethod, error) {
if record == nil {
return nil, merrors.InvalidArgument("payment_method_record is required")
}
pm, err := decodePaymentMethodProto(record.GetPaymentMethod())
if err != nil {
return nil, err
}
if err := applyPermissionBound(pm, record.GetPermissionBound()); err != nil {
return nil, err
}
return pm, nil
}
func decodePaymentMethodProto(method *endpointv1.PaymentMethod) (*model.PaymentMethod, error) {
if method == nil {
return nil, merrors.InvalidArgument("payment_method is required")
}
recipientRef, err := parseRequiredObjectID(method.GetRecipientRef(), "payment_method.recipient_ref")
if err != nil {
return nil, err
}
pt, err := paymentTypeFromProto(method.GetType(), "payment_method.type")
if err != nil {
return nil, err
}
return &model.PaymentMethod{
Describable: describableFromProto(method.GetDescribable()),
RecipientRef: recipientRef,
Type: pt,
Data: cloneBytes(method.GetData()),
IsMain: method.GetIsMain(),
}, nil
}
func encodePaymentMethodProto(pm *model.PaymentMethod) (*endpointv1.PaymentMethod, error) {
if pm == nil {
return nil, merrors.InvalidArgument("payment method is required")
}
pt, err := paymentTypeToProto(pm.Type)
if err != nil {
return nil, err
}
return &endpointv1.PaymentMethod{
Describable: describableToProto(pm.Describable),
RecipientRef: toObjectHex(pm.RecipientRef),
Type: pt,
Data: cloneBytes(pm.Data),
IsMain: pm.IsMain,
}, nil
}
func encodePaymentMethodRecord(pm *model.PaymentMethod) (*endpointv1.PaymentMethodRecord, error) {
method, err := encodePaymentMethodProto(pm)
if err != nil {
return nil, err
}
return &endpointv1.PaymentMethodRecord{
PermissionBound: permissionBoundFromModel(pm),
PaymentMethod: method,
}, nil
}
func decodePaymentMethods(items []*endpointv1.PaymentMethodRecord) ([]model.PaymentMethod, error) {
if len(items) == 0 {
return nil, nil
}
res := make([]model.PaymentMethod, 0, len(items))
for i := range items {
pm, err := decodePaymentMethodRecord(items[i])
if err != nil {
return nil, err
}
res = append(res, *pm)
}
return res, nil
}
func paymentTypeFromProto(value endpointv1.PaymentMethodType, field string) (model.PaymentType, error) {
switch value {
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN:
return model.PaymentTypeIban, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD:
return model.PaymentTypeCard, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN:
return model.PaymentTypeCardToken, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT:
return model.PaymentTypeBankAccount, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET:
return model.PaymentTypeWallet, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS:
return model.PaymentTypeCryptoAddress, nil
case endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER:
return model.PaymentTypeLedger, nil
default:
return model.PaymentTypeIban, merrors.InvalidArgument(fmt.Sprintf("%s has unsupported value: %s", field, value.String()), field)
}
}
func paymentTypeToProto(value model.PaymentType) (endpointv1.PaymentMethodType, error) {
switch value {
case model.PaymentTypeIban:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_IBAN, nil
case model.PaymentTypeCard:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD, nil
case model.PaymentTypeCardToken:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CARD_TOKEN, nil
case model.PaymentTypeBankAccount:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_BANK_ACCOUNT, nil
case model.PaymentTypeWallet:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_WALLET, nil
case model.PaymentTypeCryptoAddress:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_CRYPTO_ADDRESS, nil
case model.PaymentTypeLedger:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_LEDGER, nil
default:
return endpointv1.PaymentMethodType_PAYMENT_METHOD_TYPE_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("unsupported payment method type: %s", value.String()), "type")
}
}
func describableFromProto(src *describablev1.Describable) model.Describable {
if src == nil {
return model.Describable{}
}
res := model.Describable{Name: src.GetName()}
if src.Description != nil {
v := src.GetDescription()
res.Description = &v
}
return res
}
func describableToProto(src model.Describable) *describablev1.Describable {
if strings.TrimSpace(src.Name) == "" && src.Description == nil {
return nil
}
res := &describablev1.Describable{
Name: src.Name,
}
if src.Description != nil {
v := *src.Description
res.Description = &v
}
return res
}
func cloneBytes(src []byte) []byte {
if len(src) == 0 {
return nil
}
dst := make([]byte, len(src))
copy(dst, src)
return dst
}
func permissionBoundFromModel(pm *model.PaymentMethod) *pboundv1.PermissionBound {
if pm == nil {
return nil
}
return &pboundv1.PermissionBound{
Storable: &storablev1.Storable{
Id: toObjectHex(pm.ID),
CreatedAt: toProtoTime(pm.CreatedAt),
UpdatedAt: toProtoTime(pm.UpdatedAt),
},
Archivable: &archivablev1.Archivable{
IsArchived: pm.Archived,
},
OrganizationBound: &oboundv1.OrganizationBound{
OrganizationRef: toObjectHex(pm.GetOrganizationRef()),
},
PermissionRef: toObjectHex(pm.GetPermissionRef()),
}
}
func applyPermissionBound(pm *model.PaymentMethod, src *pboundv1.PermissionBound) error {
if pm == nil || src == nil {
return nil
}
if storable := src.GetStorable(); storable != nil {
if methodRef, err := parseOptionalObjectID(storable.GetId(), "payment_method_record.permission_bound.storable.id"); err != nil {
return err
} else if methodRef != bson.NilObjectID {
pm.ID = methodRef
}
pm.CreatedAt = fromProtoTime(storable.GetCreatedAt())
pm.UpdatedAt = fromProtoTime(storable.GetUpdatedAt())
}
if archivable := src.GetArchivable(); archivable != nil {
pm.Archived = archivable.GetIsArchived()
}
if orgBound := src.GetOrganizationBound(); orgBound != nil {
if orgRef, err := parseOptionalObjectID(orgBound.GetOrganizationRef(), "payment_method_record.permission_bound.organization_bound.organization_ref"); err != nil {
return err
} else if orgRef != bson.NilObjectID {
pm.SetOrganizationRef(orgRef)
}
}
if permissionRef, err := parseOptionalObjectID(src.GetPermissionRef(), "payment_method_record.permission_bound.permission_ref"); err != nil {
return err
} else if permissionRef != bson.NilObjectID {
pm.SetPermissionRef(permissionRef)
}
return nil
}
func parseOptionalObjectID(value, field string) (bson.ObjectID, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return bson.NilObjectID, nil
}
ref, err := bson.ObjectIDFromHex(trimmed)
if err != nil {
return bson.NilObjectID, merrors.InvalidArgument(fmt.Sprintf("%s must be a valid object id", field), field)
}
return ref, nil
}
func parseRequiredObjectID(value, field string) (bson.ObjectID, error) {
ref, err := parseOptionalObjectID(value, field)
if err != nil {
return bson.NilObjectID, err
}
if ref == bson.NilObjectID {
return bson.NilObjectID, merrors.InvalidArgument(field+" is required", field)
}
return ref, nil
}
func toObjectHex(value bson.ObjectID) string {
if value == bson.NilObjectID {
return ""
}
return value.Hex()
}
func toProtoTime(value time.Time) *timestamppb.Timestamp {
if value.IsZero() {
return nil
}
return timestamppb.New(value)
}
func fromProtoTime(value *timestamppb.Timestamp) time.Time {
if value == nil {
return time.Time{}
}
return value.AsTime()
}
func grpcErrorResponse(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
statusErr, ok := status.FromError(err)
if !ok {
return response.Internal(logger, source, err)
}
switch statusErr.Code() {
case codes.InvalidArgument:
return response.BadRequest(logger, source, "invalid_argument", statusErr.Message())
case codes.NotFound:
return response.NotFound(logger, source, statusErr.Message())
case codes.PermissionDenied:
return response.AccessDenied(logger, source, statusErr.Message())
case codes.Unauthenticated:
return response.Unauthorized(logger, source, statusErr.Message())
case codes.AlreadyExists, codes.Aborted:
return response.DataConflict(logger, source, statusErr.Message())
case codes.Unimplemented:
return response.NotImplemented(logger, source, statusErr.Message())
case codes.FailedPrecondition:
return response.Error(logger, source, http.StatusPreconditionFailed, "failed_precondition", statusErr.Message())
case codes.DeadlineExceeded:
return response.Error(logger, source, http.StatusGatewayTimeout, "deadline_exceeded", statusErr.Message())
case codes.Unavailable:
return response.Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", statusErr.Message())
default:
return response.Internal(logger, source, err)
}
}

View File

@@ -0,0 +1,94 @@
package permissionsimp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PermissionsAPI) changePolicies(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req srequest.ChangePolicies
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode role policies change request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Roles, err)
}
if req.Add != nil && req.Remove != nil {
for _, addItem := range *req.Add {
for _, removeItem := range *req.Remove {
if addItem == removeItem {
a.logger.Debug("Duplicate policies found, rejecting policies update request", zap.Any("add", &addItem), zap.Any("remove", &removeItem))
return response.BadRequest(a.logger, a.Name(), "invalid_policies_change_request", "duplicate policies found in 'add' and 'remove' fields")
}
}
}
}
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
return a.changePoliciesImp(ctx, account, &req)
}); err != nil {
a.logger.Debug("Rolling policies changes back", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}
func (a *PermissionsAPI) changePoliciesImp(
ctx context.Context,
account *model.Account,
req *srequest.ChangePolicies,
) (any, error) {
// helper that runs through each change-item, enforces the right action,
// and then calls apply(item) if enforcement passes.
handle := func(items *[]model.RolePolicy, action model.Action, opName string, apply func(context.Context, *model.RolePolicy) error) error {
for _, it := range *items {
// 1) permission check
ok, err := a.enforcer.Enforce(ctx, a.policiesPermissionRef, account.ID, it.OrganizationRef, bson.NilObjectID, action)
if err != nil {
a.logger.Warn(fmt.Sprintf("failed to enforce permission while %s policy", opName), zap.Error(err), zap.Any(opName, &it))
return err
}
if !ok {
a.logger.Debug(fmt.Sprintf("policy %s denied", opName))
return merrors.AccessDenied(mservice.Policies, string(action), bson.NilObjectID)
}
// 2) perform the add/remove
if err := apply(ctx, &it); err != nil {
a.logger.Warn(fmt.Sprintf("failed to %s role policy", opName), zap.Error(err), zap.Any("policy", &it))
return err
}
}
return nil
}
// REMOVE
if req.Remove != nil {
if err := handle(req.Remove, model.ActionDelete, "remove", func(ctx context.Context, it *model.RolePolicy) error {
return a.auth.Permission().RevokeFromRole(ctx, it)
}); err != nil {
return nil, err
}
}
// ADD
if req.Add != nil {
if err := handle(req.Add, model.ActionCreate, "add", func(ctx context.Context, it *model.RolePolicy) error {
return a.auth.Permission().GrantToRole(ctx, it)
}); err != nil {
return nil, err
}
}
return nil, nil
}

View File

@@ -0,0 +1,85 @@
package permissionsimp
import (
"context"
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PermissionsAPI) changeRole(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
orgRef, err := mutil.GetOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
}
var req srequest.ChangeRole
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode change role request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Roles, err)
}
ctx := r.Context()
res, err := a.enforcer.Enforce(ctx, a.rolesPermissionRef, account.ID, orgRef, req.AccountRef, model.ActionUpdate)
if err != nil {
a.logger.Warn("Failed to check permissions while assigning new role", zap.Error(err),
mzap.ObjRef("requesting_account_ref", account.ID), mzap.AccRef(req.AccountRef),
mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
}
if !res {
a.logger.Debug("Permission denied to set new role", mzap.ObjRef("requesting_account_ref", account.ID),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.AccessDenied(a.logger, a.Name(), "no permission to change user roles")
}
var roleDescription model.RoleDescription
if err := a.rdb.Get(ctx, req.NewRoleDescriptionRef, &roleDescription); err != nil {
a.logger.Warn("Failed to fetch and validate role description", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
return a.changeRoleImp(ctx, &req, orgRef, account)
}
func (a *PermissionsAPI) changeRoleImp(ctx context.Context, req *srequest.ChangeRole, organizationRef bson.ObjectID, account *model.Account) http.HandlerFunc {
roles, err := a.enforcer.GetRoles(ctx, req.AccountRef, organizationRef)
// TODO: add check that role revocation won't leave venue without the owner
if err != nil {
a.logger.Warn("Failed to fetch account roles", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
for _, role := range roles {
if err := a.manager.Role().Revoke(ctx, role.DescriptionRef, req.AccountRef, organizationRef); err != nil {
a.logger.Warn("Failed to revoke old role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.ObjRef("role_ref", role.DescriptionRef))
// continue...
}
}
role := model.Role{
AccountRef: req.AccountRef,
OrganizationRef: organizationRef,
DescriptionRef: req.NewRoleDescriptionRef,
}
if err := a.manager.Role().Assign(ctx, &role); err != nil {
a.logger.Warn("Failed to assign new role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.ObjRef("role_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}

View File

@@ -0,0 +1,29 @@
package permissionsimp
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *PermissionsAPI) createRoleDescription(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
var req model.RoleDescription
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode role creation request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Roles, err)
}
if err := a.rdb.Create(r.Context(), &req); err != nil {
a.logger.Warn("Failed to create role description", zap.Error(err),
mzap.ObjRef("requesting_account_ref", account.ID), zap.String("role_name", req.Name))
return response.Auto(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}

View File

@@ -0,0 +1,28 @@
package permissionsimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *PermissionsAPI) deleteRoleDescription(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
roleDescriptionRef, err := a.Rph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to restore object reference", zap.Error(err), mutil.PLog(a.Rph, r))
return response.BadReference(a.logger, a.Name(), a.Rph.Name(), a.Rph.GetID(r), err)
}
if err := a.rdb.Delete(r.Context(), roleDescriptionRef); err != nil {
a.logger.Warn("Failed to delete role description", zap.Error(err),
mzap.ObjRef("requesting_account_ref", account.ID), mzap.ObjRef("role_ref", roleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}

View File

@@ -0,0 +1,51 @@
package permissionsimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *PermissionsAPI) get(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
orgRef, err := mutil.GetOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
return response.BadReference(a.logger, a.Name(), mutil.OrganizationRefName(), mutil.GetOrganizationID(r), err)
}
ctx := r.Context()
roles, permissions, err := a.enforcer.GetPermissions(ctx, *account.GetID(), orgRef)
if len(roles) == 0 {
a.logger.Warn("No roles defined for account", mzap.StorableRef(account), mzap.ObjRef("organization_ref", orgRef))
return response.AccessDenied(a.logger, a.Name(), "User has no roles assigned")
}
if err != nil {
a.logger.Warn("Failed to fetch account policies", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Internal(a.logger, a.Name(), err)
}
roleDescs, err := a.rdb.List(ctx, orgRef, nil)
if err != nil {
a.logger.Warn("Failed to fetch organization roles", mzap.ObjRef("organization_ref", orgRef))
return response.Internal(a.logger, a.Name(), err)
}
policies, err := a.getRolePolicies(ctx, roleDescs)
if err != nil {
a.logger.Warn("Failed to fetch roles policies", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
permDescs, err := a.pdb.All(ctx, orgRef)
if err != nil {
a.logger.Warn("Failed to fetch organization permissions", mzap.ObjRef("organization_ref", orgRef))
return response.Internal(a.logger, a.Name(), err)
}
return sresponse.Permisssions(a.logger,
roleDescs, permDescs,
roles, policies, permissions,
accessToken,
)
}

View File

@@ -0,0 +1,75 @@
package permissionsimp
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PermissionsAPI) getRolePolicies(ctx context.Context, roles []model.RoleDescription) ([]model.RolePolicy, error) {
policies := make([]model.RolePolicy, 0)
uniqueRefs := make(map[bson.ObjectID]struct{})
for _, role := range roles {
uniqueRefs[*role.GetID()] = struct{}{}
}
for ref := range uniqueRefs {
plcs, err := a.auth.Permission().GetPolicies(ctx, ref)
if err != nil {
a.logger.Warn("Failed to fetch role permissions", zap.Error(err), mzap.ObjRef("role_ref", ref))
return nil, err
}
policies = append(policies, plcs...)
}
return policies, nil
}
func (a *PermissionsAPI) getAll(r *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
orgRef, err := mutil.GetOrganizationRef(r)
if err != nil {
a.logger.Warn("Failed to restore organization reference", zap.Error(err), zap.String("organization_ref", mutil.GetOrganizationID(r)))
return response.BadReference(a.logger, a.Name(), mutil.ObjRefName(), mutil.GetOrganizationID(r), err)
}
ctx := r.Context()
res, err := a.enforcer.Enforce(ctx, a.rolesPermissionRef, account.ID, orgRef, bson.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Debug("Error occurred", zap.Error(err))
response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access to permissions denied")
response.AccessDenied(a.logger, a.Name(), "no required permissiosn to read account permissions data")
}
var org model.Organization
if err := a.db.Get(ctx, account.ID, orgRef, &org); err != nil {
a.logger.Warn("Failed to fetch venue", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err)
}
roles := make([]model.Role, 0)
permissions := make([]model.Permission, 0)
for _, employee := range org.Members {
rls, prms, err := a.enforcer.GetPermissions(ctx, employee, orgRef)
if len(rls) == 0 {
a.logger.Warn("No roles defined for account", mzap.ObjRef("employee_ref", employee), mzap.ObjRef("organization_ref", orgRef))
return response.NotFound(a.logger, a.Name(), "User has no roles assigned")
}
if err != nil {
a.logger.Warn("Failed to fetch account policies", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err)
}
roles = append(roles, rls...)
permissions = append(permissions, prms...)
}
return a.permissions(ctx, orgRef, roles, permissions, accessToken)
}

View File

@@ -0,0 +1,33 @@
package permissionsimp
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (a *PermissionsAPI) permissions(ctx context.Context, organizationRef bson.ObjectID, roles []model.Role, permissions []model.Permission, accessToken *sresponse.TokenData) http.HandlerFunc {
roleDescs, err := a.rdb.List(ctx, organizationRef, nil)
if err != nil {
a.logger.Warn("Failed to fetch organization roles", zap.Error(err), mzap.ObjRef("organization_ref", organizationRef))
return response.Internal(a.logger, a.Name(), err)
}
permDescs, err := a.pdb.All(ctx, organizationRef)
if err != nil {
a.logger.Warn("Failed to fetch organization permissions", zap.Error(err), mzap.ObjRef("organization_ref", organizationRef))
return response.Internal(a.logger, a.Name(), err)
}
policies, err := a.getRolePolicies(ctx, roleDescs)
if err != nil {
a.logger.Warn("Failed to fetch roles policies", zap.Error(err))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.Permisssions(a.logger, roleDescs, permDescs, roles, policies, permissions, accessToken)
}

View File

@@ -0,0 +1,87 @@
package permissionsimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/organization"
"github.com/tech/sendico/pkg/db/policy"
"github.com/tech/sendico/pkg/db/role"
"github.com/tech/sendico/pkg/db/transaction"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type PermissionsAPI struct {
logger mlogger.Logger
db organization.DB
pdb policy.DB
rdb role.DB
enforcer auth.Enforcer
manager auth.Manager
rolesPermissionRef bson.ObjectID
policiesPermissionRef bson.ObjectID
Rph mutil.ParamHelper
tf transaction.Factory
auth auth.Manager
}
func (a *PermissionsAPI) Name() mservice.Type {
return mservice.Permissions
}
func (a *PermissionsAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*PermissionsAPI, error) {
p := &PermissionsAPI{
enforcer: a.Permissions().Enforcer(),
manager: a.Permissions().Manager(),
Rph: mutil.CreatePH("role"),
tf: a.DBFactory().TransactionFactory(),
auth: a.Permissions().Manager(),
}
p.logger = a.Logger().Named(p.Name())
var err error
if p.db, err = a.DBFactory().NewOrganizationDB(); err != nil {
p.logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
if p.rdb, err = a.DBFactory().NewRolesDB(); err != nil {
p.logger.Error("Failed to create roles database", zap.Error(err))
return nil, err
}
if p.pdb, err = a.DBFactory().NewPoliciesDB(); err != nil {
p.logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
var pdesc model.PolicyDescription
if err := p.pdb.GetBuiltInPolicy(context.Background(), mservice.Roles, &pdesc); err != nil {
p.logger.Warn("Failed to fetch roles management permission description", zap.Error(err))
return nil, err
}
p.rolesPermissionRef = pdesc.ID
if err := p.pdb.GetBuiltInPolicy(context.Background(), mservice.Policies, &pdesc); err != nil {
p.logger.Warn("Failed to fetch policies management permission description", zap.Error(err))
return nil, err
}
p.policiesPermissionRef = pdesc.ID
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/"), api.Get, p.get)
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/all"), api.Get, p.getAll)
a.Register().AccountHandler(p.Name(), mutil.AddOrganizaztionRef("/change_role"), api.Post, p.changeRole)
a.Register().AccountHandler(p.Name(), "/policies", api.Put, p.changePolicies)
a.Register().AccountHandler(p.Name(), "/role", api.Post, p.createRoleDescription)
a.Register().AccountHandler(p.Name(), p.Rph.AddRef("/role"), api.Delete, p.deleteRoleDescription)
return p, nil
}

View File

@@ -0,0 +1,22 @@
package recipientimp
import (
messaging "github.com/tech/sendico/pkg/messaging/envelope"
notifications "github.com/tech/sendico/pkg/messaging/notifications/object"
"github.com/tech/sendico/pkg/model"
nm "github.com/tech/sendico/pkg/model/notification"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (a *RecipientAPI) notification(
recipient *model.Recipient,
actorAccountRef bson.ObjectID,
t nm.NotificationAction,
) messaging.Envelope {
objectRef := bson.NilObjectID
if recipient != nil {
objectRef = recipient.ID
}
return notifications.Object(a.Name(), actorAccountRef, a.Name(), objectRef, t)
}

View File

@@ -0,0 +1,50 @@
package recipientimp
import (
"context"
"github.com/tech/sendico/pkg/db/recipient"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/papitemplate"
"go.uber.org/zap"
)
type RecipientAPI struct {
papitemplate.ProtectedAPI[model.Recipient]
db recipient.DB
}
func (a *RecipientAPI) Name() mservice.Type {
return mservice.Recipients
}
func (a *RecipientAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*RecipientAPI, error) {
dbFactory := func() (papitemplate.ProtectedDB[model.Recipient], error) {
return a.DBFactory().NewRecipientsDB()
}
res := &RecipientAPI{}
p, err := papitemplate.CreateAPI(a, dbFactory, mservice.Organizations, mservice.Recipients)
if err != nil {
return nil, err
}
res.ProtectedAPI = *p.
WithNotifications(res.notification).
WithNoCreateNotification().
WithNoUpdateNotification().
Build()
if res.db, err = a.DBFactory().NewRecipientsDB(); err != nil {
res.Logger.Warn("Failed to create recipients database", zap.Error(err))
return nil, err
}
return res, nil
}

View File

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

View File

@@ -0,0 +1,29 @@
package siteimp
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *SiteAPI) callRequest(r *http.Request) http.HandlerFunc {
var request model.CallRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
a.logger.Warn("Failed to decode call request payload", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "invalid_payload", "Failed to decode call request payload")
}
request.Normalize()
if err := request.Validate(); err != nil {
a.logger.Warn("Call request validation failed", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.producer.SendMessage(snotifications.CallRequest(a.Name(), &request)); err != nil {
a.logger.Warn("Failed to enqueue call request notification", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return a.acceptedQueued()
}

View File

@@ -0,0 +1,32 @@
package siteimp
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *SiteAPI) contactRequest(r *http.Request) http.HandlerFunc {
var request model.ContactRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
a.logger.Warn("Failed to decode contact request payload", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "invalid_payload", "Failed to decode contact request payload")
}
request.Normalize()
if request.Topic == "" {
request.Topic = model.ContactRequestTopicSiteContact
}
if err := request.Validate(); err != nil {
a.logger.Warn("Contact request validation failed", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.producer.SendMessage(snotifications.ContactRequest(a.Name(), &request)); err != nil {
a.logger.Warn("Failed to enqueue contact request notification", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return a.acceptedQueued()
}

View File

@@ -0,0 +1,31 @@
package siteimp
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
snotifications "github.com/tech/sendico/pkg/messaging/notifications/site"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
func (a *SiteAPI) demoRequest(r *http.Request) http.HandlerFunc {
var request model.DemoRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
a.logger.Warn("Failed to decode demo request payload", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "invalid_payload", "Failed to decode demo request payload")
}
request.Normalize()
if err := request.Validate(); err != nil {
a.logger.Warn("Demo request validation failed", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.producer.SendMessage(snotifications.DemoRequest(a.Name(), &request)); err != nil {
a.logger.Warn("Failed to enqueue demo request notification", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
return a.acceptedQueued()
}

View File

@@ -0,0 +1,19 @@
package siteimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
)
type enqueueResponse struct {
Status string `json:"status"`
}
func newEnqueueResponse() enqueueResponse {
return enqueueResponse{Status: "queued"}
}
func (a *SiteAPI) acceptedQueued() http.HandlerFunc {
return response.Accepted(a.logger, newEnqueueResponse())
}

View File

@@ -0,0 +1,36 @@
package siteimp
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
)
type SiteAPI struct {
logger mlogger.Logger
producer messaging.Producer
}
func (a *SiteAPI) Name() mservice.Type {
return mservice.Site
}
func (a *SiteAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*SiteAPI, error) {
p := &SiteAPI{
logger: a.Logger().Named(mservice.Site),
producer: a.Register().Messaging().Producer(),
}
a.Register().Handler(mservice.Site, "/request/demo", api.Post, p.demoRequest)
a.Register().Handler(mservice.Site, "/request/contact", api.Post, p.contactRequest)
a.Register().Handler(mservice.Site, "/request/call", api.Post, p.callRequest)
return p, nil
}

Some files were not shown because too many files have changed in this diff Show More