move api/server to api/edge/bff
This commit is contained in:
130
api/edge/bff/internal/server/aapitemplate/config.go
Normal file
130
api/edge/bff/internal/server/aapitemplate/config.go
Normal 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,
|
||||
}
|
||||
}
|
||||
31
api/edge/bff/internal/server/aapitemplate/create.go
Normal file
31
api/edge/bff/internal/server/aapitemplate/create.go
Normal 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)
|
||||
}
|
||||
21
api/edge/bff/internal/server/aapitemplate/db.go
Normal file
21
api/edge/bff/internal/server/aapitemplate/db.go
Normal 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
|
||||
}
|
||||
53
api/edge/bff/internal/server/aapitemplate/delete.go
Normal file
53
api/edge/bff/internal/server/aapitemplate/delete.go
Normal 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)
|
||||
}
|
||||
29
api/edge/bff/internal/server/aapitemplate/get.go
Normal file
29
api/edge/bff/internal/server/aapitemplate/get.go
Normal 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)
|
||||
}
|
||||
33
api/edge/bff/internal/server/aapitemplate/list.go
Normal file
33
api/edge/bff/internal/server/aapitemplate/list.go
Normal 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)
|
||||
}
|
||||
88
api/edge/bff/internal/server/aapitemplate/nconfig.go
Normal file
88
api/edge/bff/internal/server/aapitemplate/nconfig.go
Normal 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
|
||||
}
|
||||
33
api/edge/bff/internal/server/aapitemplate/rconfig.go
Normal file
33
api/edge/bff/internal/server/aapitemplate/rconfig.go
Normal 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
|
||||
}
|
||||
33
api/edge/bff/internal/server/aapitemplate/reorder.go
Normal file
33
api/edge/bff/internal/server/aapitemplate/reorder.go
Normal 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)
|
||||
}
|
||||
19
api/edge/bff/internal/server/aapitemplate/responses.go
Normal file
19
api/edge/bff/internal/server/aapitemplate/responses.go
Normal 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())
|
||||
}
|
||||
181
api/edge/bff/internal/server/aapitemplate/service.go
Normal file
181
api/edge/bff/internal/server/aapitemplate/service.go
Normal 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
|
||||
}
|
||||
31
api/edge/bff/internal/server/aapitemplate/update.go
Normal file
31
api/edge/bff/internal/server/aapitemplate/update.go
Normal 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)
|
||||
}
|
||||
95
api/edge/bff/internal/server/accountapiimp/account.go
Executable file
95
api/edge/bff/internal/server/accountapiimp/account.go
Executable 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")
|
||||
}
|
||||
130
api/edge/bff/internal/server/accountapiimp/delete.go
Normal file
130
api/edge/bff/internal/server/accountapiimp/delete.go
Normal 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)
|
||||
}
|
||||
49
api/edge/bff/internal/server/accountapiimp/dzone.go
Normal file
49
api/edge/bff/internal/server/accountapiimp/dzone.go
Normal 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,
|
||||
)
|
||||
}
|
||||
61
api/edge/bff/internal/server/accountapiimp/email.go
Normal file
61
api/edge/bff/internal/server/accountapiimp/email.go
Normal 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)
|
||||
}
|
||||
43
api/edge/bff/internal/server/accountapiimp/employees.go
Normal file
43
api/edge/bff/internal/server/accountapiimp/employees.go
Normal 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)
|
||||
}
|
||||
83
api/edge/bff/internal/server/accountapiimp/empupdate.go
Normal file
83
api/edge/bff/internal/server/accountapiimp/empupdate.go
Normal 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)
|
||||
}
|
||||
196
api/edge/bff/internal/server/accountapiimp/password.go
Normal file
196
api/edge/bff/internal/server/accountapiimp/password.go
Normal 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
|
||||
}
|
||||
278
api/edge/bff/internal/server/accountapiimp/password_test.go
Normal file
278
api/edge/bff/internal/server/accountapiimp/password_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
278
api/edge/bff/internal/server/accountapiimp/service.go
Normal file
278
api/edge/bff/internal/server/accountapiimp/service.go
Normal 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))
|
||||
}
|
||||
}
|
||||
19
api/edge/bff/internal/server/accountapiimp/service_test.go
Normal file
19
api/edge/bff/internal/server/accountapiimp/service_test.go
Normal 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())
|
||||
}
|
||||
382
api/edge/bff/internal/server/accountapiimp/signup.go
Normal file
382
api/edge/bff/internal/server/accountapiimp/signup.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
116
api/edge/bff/internal/server/accountapiimp/signup_ledger_test.go
Normal file
116
api/edge/bff/internal/server/accountapiimp/signup_ledger_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
355
api/edge/bff/internal/server/accountapiimp/signup_test.go
Normal file
355
api/edge/bff/internal/server/accountapiimp/signup_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
31
api/edge/bff/internal/server/accountapiimp/token.go
Normal file
31
api/edge/bff/internal/server/accountapiimp/token.go
Normal 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)
|
||||
}
|
||||
61
api/edge/bff/internal/server/accountapiimp/update.go
Normal file
61
api/edge/bff/internal/server/accountapiimp/update.go
Normal 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)
|
||||
}
|
||||
42
api/edge/bff/internal/server/fileserviceimp/fileserver.go
Normal file
42
api/edge/bff/internal/server/fileserviceimp/fileserver.go
Normal 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)
|
||||
}
|
||||
46
api/edge/bff/internal/server/fileserviceimp/service.go
Normal file
46
api/edge/bff/internal/server/fileserviceimp/service.go
Normal 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
|
||||
}
|
||||
136
api/edge/bff/internal/server/fileserviceimp/storage/awss3.go
Normal file
136
api/edge/bff/internal/server/fileserviceimp/storage/awss3.go
Normal 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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package config
|
||||
|
||||
type LocalFSSConfig struct {
|
||||
RootPath string `mapstructure:"root_path" yaml:"root_path"`
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
147
api/edge/bff/internal/server/fileserviceimp/storage/localfs.go
Normal file
147
api/edge/bff/internal/server/fileserviceimp/storage/localfs.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
120
api/edge/bff/internal/server/internal/serverimp.go
Normal file
120
api/edge/bff/internal/server/internal/serverimp.go
Normal 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
|
||||
}
|
||||
136
api/edge/bff/internal/server/invitationimp/accept.go
Normal file
136
api/edge/bff/internal/server/invitationimp/accept.go
Normal 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)
|
||||
}
|
||||
24
api/edge/bff/internal/server/invitationimp/decline.go
Normal file
24
api/edge/bff/internal/server/invitationimp/decline.go
Normal 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)
|
||||
}
|
||||
19
api/edge/bff/internal/server/invitationimp/notifications.go
Normal file
19
api/edge/bff/internal/server/invitationimp/notifications.go
Normal 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)
|
||||
}
|
||||
26
api/edge/bff/internal/server/invitationimp/public.go
Normal file
26
api/edge/bff/internal/server/invitationimp/public.go
Normal 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)
|
||||
}
|
||||
13
api/edge/bff/internal/server/invitationimp/response.go
Normal file
13
api/edge/bff/internal/server/invitationimp/response.go
Normal 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)
|
||||
}
|
||||
84
api/edge/bff/internal/server/invitationimp/service.go
Normal file
84
api/edge/bff/internal/server/invitationimp/service.go
Normal 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
|
||||
}
|
||||
53
api/edge/bff/internal/server/ledgerapiimp/balance.go
Normal file
53
api/edge/bff/internal/server/ledgerapiimp/balance.go
Normal 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)
|
||||
}
|
||||
146
api/edge/bff/internal/server/ledgerapiimp/create.go
Normal file
146
api/edge/bff/internal/server/ledgerapiimp/create.go
Normal 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
|
||||
}
|
||||
55
api/edge/bff/internal/server/ledgerapiimp/list.go
Normal file
55
api/edge/bff/internal/server/ledgerapiimp/list.go
Normal 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)
|
||||
}
|
||||
112
api/edge/bff/internal/server/ledgerapiimp/service.go
Normal file
112
api/edge/bff/internal/server/ledgerapiimp/service.go
Normal 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
|
||||
}
|
||||
40
api/edge/bff/internal/server/logoimp/logo.go
Normal file
40
api/edge/bff/internal/server/logoimp/logo.go
Normal 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
|
||||
}
|
||||
40
api/edge/bff/internal/server/logoimp/service.go
Normal file
40
api/edge/bff/internal/server/logoimp/service.go
Normal 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
|
||||
}
|
||||
71
api/edge/bff/internal/server/organizationimp/crud.go
Normal file
71
api/edge/bff/internal/server/organizationimp/crud.go
Normal 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)
|
||||
}
|
||||
37
api/edge/bff/internal/server/organizationimp/invitation.go
Normal file
37
api/edge/bff/internal/server/organizationimp/invitation.go
Normal 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)
|
||||
}
|
||||
59
api/edge/bff/internal/server/organizationimp/service.go
Normal file
59
api/edge/bff/internal/server/organizationimp/service.go
Normal 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
|
||||
}
|
||||
69
api/edge/bff/internal/server/papitemplate/archive.go
Normal file
69
api/edge/bff/internal/server/papitemplate/archive.go
Normal 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)
|
||||
}
|
||||
133
api/edge/bff/internal/server/papitemplate/config.go
Normal file
133
api/edge/bff/internal/server/papitemplate/config.go
Normal 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,
|
||||
}
|
||||
}
|
||||
38
api/edge/bff/internal/server/papitemplate/create.go
Normal file
38
api/edge/bff/internal/server/papitemplate/create.go
Normal 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)
|
||||
}
|
||||
23
api/edge/bff/internal/server/papitemplate/db.go
Normal file
23
api/edge/bff/internal/server/papitemplate/db.go
Normal 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
|
||||
}
|
||||
67
api/edge/bff/internal/server/papitemplate/delete.go
Normal file
67
api/edge/bff/internal/server/papitemplate/delete.go
Normal 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)
|
||||
}
|
||||
29
api/edge/bff/internal/server/papitemplate/get.go
Normal file
29
api/edge/bff/internal/server/papitemplate/get.go
Normal 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)
|
||||
}
|
||||
42
api/edge/bff/internal/server/papitemplate/list.go
Normal file
42
api/edge/bff/internal/server/papitemplate/list.go
Normal 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)
|
||||
}
|
||||
88
api/edge/bff/internal/server/papitemplate/nconfig.go
Normal file
88
api/edge/bff/internal/server/papitemplate/nconfig.go
Normal 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
|
||||
}
|
||||
33
api/edge/bff/internal/server/papitemplate/rconfig.go
Normal file
33
api/edge/bff/internal/server/papitemplate/rconfig.go
Normal 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
|
||||
}
|
||||
33
api/edge/bff/internal/server/papitemplate/reorder.go
Normal file
33
api/edge/bff/internal/server/papitemplate/reorder.go
Normal 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)
|
||||
}
|
||||
19
api/edge/bff/internal/server/papitemplate/responses.go
Normal file
19
api/edge/bff/internal/server/papitemplate/responses.go
Normal 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())
|
||||
}
|
||||
203
api/edge/bff/internal/server/papitemplate/service.go
Normal file
203
api/edge/bff/internal/server/papitemplate/service.go
Normal 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
|
||||
}
|
||||
122
api/edge/bff/internal/server/papitemplate/taggable.go
Normal file
122
api/edge/bff/internal/server/papitemplate/taggable.go
Normal 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,
|
||||
})
|
||||
}
|
||||
80
api/edge/bff/internal/server/papitemplate/tconfig.go
Normal file
80
api/edge/bff/internal/server/papitemplate/tconfig.go
Normal 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
|
||||
}
|
||||
31
api/edge/bff/internal/server/papitemplate/update.go
Normal file
31
api/edge/bff/internal/server/papitemplate/update.go
Normal 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)
|
||||
}
|
||||
25
api/edge/bff/internal/server/paymentapiimp/customer.go
Normal file
25
api/edge/bff/internal/server/paymentapiimp/customer.go
Normal 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)
|
||||
}
|
||||
95
api/edge/bff/internal/server/paymentapiimp/discovery.go
Normal file
95
api/edge/bff/internal/server/paymentapiimp/discovery.go
Normal 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
|
||||
}
|
||||
172
api/edge/bff/internal/server/paymentapiimp/documents.go
Normal file
172
api/edge/bff/internal/server/paymentapiimp/documents.go
Normal 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)
|
||||
}
|
||||
}
|
||||
41
api/edge/bff/internal/server/paymentapiimp/grpc_error.go
Normal file
41
api/edge/bff/internal/server/paymentapiimp/grpc_error.go
Normal 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)
|
||||
}
|
||||
}
|
||||
199
api/edge/bff/internal/server/paymentapiimp/list.go
Normal file
199
api/edge/bff/internal/server/paymentapiimp/list.go
Normal 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
|
||||
}
|
||||
319
api/edge/bff/internal/server/paymentapiimp/mapper.go
Normal file
319
api/edge/bff/internal/server/paymentapiimp/mapper.go
Normal 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 := "ationv2.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"`
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
144
api/edge/bff/internal/server/paymentapiimp/pay.go
Normal file
144
api/edge/bff/internal/server/paymentapiimp/pay.go
Normal 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, "ationv2.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)
|
||||
}
|
||||
65
api/edge/bff/internal/server/paymentapiimp/pay_test.go
Normal file
65
api/edge/bff/internal/server/paymentapiimp/pay_test.go
Normal 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
|
||||
}
|
||||
80
api/edge/bff/internal/server/paymentapiimp/paybatch.go
Normal file
80
api/edge/bff/internal/server/paymentapiimp/paybatch.go
Normal 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
|
||||
}
|
||||
172
api/edge/bff/internal/server/paymentapiimp/paybatch_test.go
Normal file
172
api/edge/bff/internal/server/paymentapiimp/paybatch_test.go
Normal 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)
|
||||
13
api/edge/bff/internal/server/paymentapiimp/payimmediate.go
Normal file
13
api/edge/bff/internal/server/paymentapiimp/payimmediate.go
Normal 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)
|
||||
}
|
||||
13
api/edge/bff/internal/server/paymentapiimp/payquote.go
Normal file
13
api/edge/bff/internal/server/paymentapiimp/payquote.go
Normal 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)
|
||||
}
|
||||
153
api/edge/bff/internal/server/paymentapiimp/quote.go
Normal file
153
api/edge/bff/internal/server/paymentapiimp/quote.go
Normal 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 := "ationv2.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 := "ationv2.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
|
||||
}
|
||||
293
api/edge/bff/internal/server/paymentapiimp/service.go
Normal file
293
api/edge/bff/internal/server/paymentapiimp/service.go
Normal 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
|
||||
}
|
||||
621
api/edge/bff/internal/server/paymethodsimp/service.go
Normal file
621
api/edge/bff/internal/server/paymethodsimp/service.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
85
api/edge/bff/internal/server/permissionsimp/changerole.go
Normal file
85
api/edge/bff/internal/server/permissionsimp/changerole.go
Normal 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)
|
||||
}
|
||||
29
api/edge/bff/internal/server/permissionsimp/createrole.go
Normal file
29
api/edge/bff/internal/server/permissionsimp/createrole.go
Normal 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)
|
||||
}
|
||||
28
api/edge/bff/internal/server/permissionsimp/deleterole.go
Normal file
28
api/edge/bff/internal/server/permissionsimp/deleterole.go
Normal 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)
|
||||
}
|
||||
51
api/edge/bff/internal/server/permissionsimp/get.go
Normal file
51
api/edge/bff/internal/server/permissionsimp/get.go
Normal 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,
|
||||
)
|
||||
}
|
||||
75
api/edge/bff/internal/server/permissionsimp/get_all.go
Normal file
75
api/edge/bff/internal/server/permissionsimp/get_all.go
Normal 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)
|
||||
}
|
||||
33
api/edge/bff/internal/server/permissionsimp/permissions.go
Normal file
33
api/edge/bff/internal/server/permissionsimp/permissions.go
Normal 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)
|
||||
}
|
||||
87
api/edge/bff/internal/server/permissionsimp/service.go
Normal file
87
api/edge/bff/internal/server/permissionsimp/service.go
Normal 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
|
||||
}
|
||||
22
api/edge/bff/internal/server/recipientimp/notifications.go
Normal file
22
api/edge/bff/internal/server/recipientimp/notifications.go
Normal 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)
|
||||
}
|
||||
50
api/edge/bff/internal/server/recipientimp/service.go
Normal file
50
api/edge/bff/internal/server/recipientimp/service.go
Normal 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
|
||||
}
|
||||
11
api/edge/bff/internal/server/server.go
Executable file
11
api/edge/bff/internal/server/server.go
Executable 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)
|
||||
}
|
||||
29
api/edge/bff/internal/server/siteimp/call.go
Normal file
29
api/edge/bff/internal/server/siteimp/call.go
Normal 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()
|
||||
}
|
||||
32
api/edge/bff/internal/server/siteimp/contact.go
Normal file
32
api/edge/bff/internal/server/siteimp/contact.go
Normal 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()
|
||||
}
|
||||
31
api/edge/bff/internal/server/siteimp/demo.go
Normal file
31
api/edge/bff/internal/server/siteimp/demo.go
Normal 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()
|
||||
}
|
||||
19
api/edge/bff/internal/server/siteimp/response.go
Normal file
19
api/edge/bff/internal/server/siteimp/response.go
Normal 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())
|
||||
}
|
||||
36
api/edge/bff/internal/server/siteimp/service.go
Normal file
36
api/edge/bff/internal/server/siteimp/service.go
Normal 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
Reference in New Issue
Block a user