fx build fix
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx/1 Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx/2 Pipeline failed

This commit is contained in:
Stephan D
2025-11-08 00:30:29 +01:00
parent 590fad0071
commit 49b86efecb
165 changed files with 9466 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
package apiimp
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/domainprovider"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account"
"github.com/tech/sendico/server/interface/services/invitation"
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
"github.com/tech/sendico/server/interface/services/permission"
"go.uber.org/zap"
)
type Microservices = []mservice.MicroService
// APIImp represents the structure of the APIImp
type APIImp struct {
logger mlogger.Logger
db db.Factory
domain domainprovider.DomainProvider
config *api.Config
services Microservices
mw *Middleware
}
func (a *APIImp) installMicroservice(srv mservice.MicroService) {
a.services = append(a.services, srv)
a.logger.Info("Microservice installed", zap.String("service", srv.Name()))
}
func (a *APIImp) addMicroservice(srvf api.MicroServiceFactoryT) error {
srv, err := srvf(a)
if err != nil {
a.logger.Error("Failed to install a microservice", zap.Error(err))
return err
}
a.installMicroservice(srv)
return nil
}
func (a *APIImp) Logger() mlogger.Logger {
return a.logger
}
func (a *APIImp) Config() *api.Config {
return a.config
}
func (a *APIImp) DBFactory() db.Factory {
return a.db
}
func (a *APIImp) DomainProvider() domainprovider.DomainProvider {
return a.domain
}
func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) Permissions() auth.Provider {
return a.db.Permissions()
}
func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, account.Create)
srvf = append(srvf, organization.Create)
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)
srvf = append(srvf, permission.Create)
for _, v := range srvf {
if err := a.addMicroservice(v); err != nil {
return err
}
}
a.mw.SetStatus(health.SSRunning)
return nil
}
func (a *APIImp) Finish(ctx context.Context) error {
a.mw.SetStatus(health.SSTerminating)
a.mw.Finish()
var lastError error
for i := len(a.services) - 1; i >= 0; i-- {
if err := (a.services[i]).Finish(ctx); err != nil {
lastError = err
a.logger.Warn("Error occurred when finishing service",
zap.Error(err), zap.String("service_name", (a.services[i]).Name()))
} else {
a.logger.Info("Microservice is down", zap.String("service_name", (a.services[i]).Name()))
}
}
return lastError
}
func (a *APIImp) Name() string {
return "api"
}
func CreateAPI(logger mlogger.Logger, config *api.Config, db db.Factory, router *chi.Mux, debug bool) (mservice.MicroService, error) {
p := &APIImp{
logger: logger.Named("api"),
config: config,
db: db,
}
var err error
if p.domain, err = domainprovider.CreateDomainProvider(p.logger, config.Mw.DomainEnv, config.Mw.APIProtocolEnv, config.Mw.EndPointEnv); err != nil {
p.logger.Error("Failed to initizlize domain provider")
return nil, err
}
p.logger.Info("Domain provider installed")
if p.mw, err = CreateMiddleware(logger, db, p.db.Permissions().Enforcer(), router, config.Mw, debug); err != nil {
p.logger.Error("Failed to create middleware", zap.Error(err))
return nil, err
}
p.logger.Info("Middleware installed", zap.Bool("debug_mode", debug))
p.logger.Info("Installing microservices...")
if err := p.installServices(); err != nil {
p.logger.Error("Failed to install a microservice", zap.Error(err))
return nil, err
}
p.logger.Info("Microservices installation complete", zap.Int("microservices", len(p.services)))
return p, nil
}

View File

@@ -0,0 +1,66 @@
package apiimp
import "github.com/tech/sendico/pkg/messaging"
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
type SignatureConf struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type Signature struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type TokenExpiration struct {
Account int `yaml:"account"`
Refresh int `yaml:"refresh"`
}
type TokenConfig struct {
Expiration TokenExpiration `yaml:"expiration_hours"`
Length int `yaml:"length"`
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
Lower bool `yaml:"lower"`
Special bool `yaml:"special"`
MinLength int `yaml:"min_length"`
}
type PasswordConfig struct {
TokenLength int `yaml:"token_length"`
Check PasswordChecks `yaml:"check"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature Signature `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Messaging messaging.Config `yaml:"message_broker"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
}
type MapClaims = map[string]any

View File

@@ -0,0 +1,138 @@
package apiimp
import (
"os"
"github.com/go-chi/chi/v5"
cm "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/metrics"
api "github.com/tech/sendico/pkg/api/http"
amr "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/health"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/notifications/processor"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
wsh "github.com/tech/sendico/server/interface/api/ws"
"github.com/tech/sendico/server/interface/middleware"
"github.com/tech/sendico/server/internal/api/routers"
mr "github.com/tech/sendico/server/internal/api/routers/metrics"
"github.com/tech/sendico/server/internal/api/ws"
"go.uber.org/zap"
"moul.io/chizap"
)
type Middleware struct {
logger mlogger.Logger
router *chi.Mux
apiEndpoint string
health amr.Health
metrics mr.Metrics
wshandler ws.Router
messaging amr.Messaging
epdispatcher *routers.Dispatcher
}
func (mw *Middleware) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
mw.epdispatcher.Handler(service, endpoint, method, handler)
}
func (mw *Middleware) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
mw.epdispatcher.AccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Consumer(processor notifications.EnvelopeProcessor) error {
return mw.messaging.Consumer(processor)
}
func (mw *Middleware) Producer() messaging.Producer {
return mw.messaging.Producer()
}
func (mw *Middleware) Messaging() messaging.Register {
return mw
}
func (mw *Middleware) Finish() {
mw.messaging.Finish()
mw.health.Finish()
}
func (mw *Middleware) SetStatus(status health.ServiceStatus) {
mw.health.SetStatus(status)
}
func (mw *Middleware) installMiddleware(config *middleware.Config, debug bool) {
mw.logger.Debug("Installing middleware stack...")
// Collect metrics for all incoming HTTP requests
mw.router.Use(metrics.Collector(metrics.CollectorOpts{
Host: false, // avoid high-cardinality "host" label
Proto: true, // include HTTP protocol label
}))
mw.router.Use(cm.RequestID)
mw.router.Use(cm.RealIP)
if debug {
mw.router.Use(chizap.New(mw.logger.Named("http_trace"), &chizap.Opts{
WithReferer: true,
WithUserAgent: true,
}))
}
mw.router.Use(cors.Handler(cors.Options{
AllowedOrigins: config.CORS.AllowedOrigins,
AllowedMethods: config.CORS.AllowedMethods,
AllowedHeaders: config.CORS.AllowedHeaders,
ExposedHeaders: config.CORS.ExposedHeaders,
AllowCredentials: config.CORS.AllowCredentials,
MaxAge: config.CORS.MaxAge,
OptionsPassthrough: false,
Debug: debug,
}))
mw.router.Use(cm.Recoverer)
mw.router.Handle("/metrics", metrics.Handler())
}
func CreateMiddleware(logger mlogger.Logger, db db.Factory, enforcer auth.Enforcer, router *chi.Mux, config *middleware.Config, debug bool) (*Middleware, error) {
p := &Middleware{
logger: logger.Named("middleware"),
router: router,
apiEndpoint: os.Getenv(config.EndPointEnv),
}
p.logger.Info("Set endpoint", zap.String("endpoint", p.apiEndpoint))
p.installMiddleware(config, debug)
var err error
if p.messaging, err = amr.NewMessagingRouter(p.logger, &config.Messaging); err != nil {
p.logger.Error("Failed to create messaging router", zap.Error(err))
return nil, err
}
if p.health, err = amr.NewHealthRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create healthcheck router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
if p.metrics, err = mr.NewMetricsRouter(p.logger, p.router, p.apiEndpoint); err != nil {
p.logger.Error("Failed to create metrics router", zap.Error(err), zap.String("api_endpoint", p.apiEndpoint))
return nil, err
}
adb, err := db.NewAccountDB()
if err != nil {
p.logger.Error("Faild to create account database", zap.Error(err))
return nil, err
}
rtdb, err := db.NewRefreshTokensDB()
if err != nil {
p.logger.Error("Faild to create refresh token management database", zap.Error(err))
return nil, err
}
p.epdispatcher = routers.NewDispatcher(p.logger, p.router, adb, rtdb, enforcer, config)
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
return p, nil
}

View File

@@ -0,0 +1,56 @@
package routers
import (
"errors"
"net/http"
"github.com/go-chi/jwtauth/v5"
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/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
type tokenHandlerFunc = func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc
func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler tokenHandlerFunc) {
hndlr := func(r *http.Request) http.HandlerFunc {
_, claims, err := jwtauth.FromContext(r.Context())
if err != nil {
ar.logger.Debug("Authorization failed", zap.Error(err), zap.String("request", r.URL.Path))
return response.Unauthorized(ar.logger, ar.service, "credentials required")
}
t, err := emodel.Claims2Token(claims)
if err != nil {
ar.logger.Debug("Failed to decode account token", zap.Error(err))
return response.BadRequest(ar.logger, ar.service, "credentials_unreadable", "faild to parse credentials")
}
return handler(r, t)
}
ar.imp.InstallHandler(service, endpoint, method, hndlr)
}
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.ObjRef("account_ref", t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
}
accessToken, err := ar.imp.CreateAccessToken(&a)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(ar.logger, ar.service, err)
}
return handler(r, &a, &accessToken)
}
ar.tokenHandler(service, endpoint, method, hndlr)
}

View File

@@ -0,0 +1,34 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
)
type AuthorizedRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
l := logger.Named("authorized")
ar := AuthorizedRouter{
logger: l,
db: db,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
return &ar
}

View File

@@ -0,0 +1,50 @@
package routers
import (
"os"
"github.com/go-chi/chi/v5"
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/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
rauthorized "github.com/tech/sendico/server/internal/api/routers/authorized"
rpublic "github.com/tech/sendico/server/internal/api/routers/public"
)
type Dispatcher struct {
logger mlogger.Logger
public APIRouter
protected ProtectedAPIRouter
}
func (d *Dispatcher) Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
d.public.InstallHandler(service, endpoint, method, handler)
}
func (d *Dispatcher) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
d.protected.AccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
d.logger.Debug("Installing endpoints middleware...")
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, rtdb, r, &config.Token, &signature)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)
})
return d
}

View File

@@ -0,0 +1,36 @@
package routers
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
)
type (
RegistratorT = func(chi.Router, string, http.HandlerFunc)
ResponderFunc = func(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc
)
type HttpEndpointRouter struct {
logger mlogger.Logger
apiEndpoint string
router chi.Router
config middleware.TokenConfig
signature middleware.Signature
}
func NewHttpEndpointRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *HttpEndpointRouter {
er := HttpEndpointRouter{
logger: logger.Named("http"),
apiEndpoint: apiEndpoint,
router: router,
signature: *signature,
config: *config,
}
return &er
}

View File

@@ -0,0 +1,50 @@
package routers
import (
"fmt"
"net/http"
"path"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (er *HttpEndpointRouter) chooseMethod(method api.HTTPMethod) RegistratorT {
switch method {
case api.Get:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Get(p, h) }
case api.Post:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Post(p, h) }
case api.Put:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Put(p, h) }
case api.Delete:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Delete(p, h) }
case api.Patch:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Patch(p, h) }
case api.Options:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Options(p, h) }
case api.Head:
return func(r chi.Router, p string, h http.HandlerFunc) { r.Head(p, h) }
default:
}
er.logger.Error("Unknown method provided", zap.String("method", api.HTTPMethod2String(method)))
panic(fmt.Sprintf("Unknown method provided: %d", method))
}
func (er *HttpEndpointRouter) endpoint(service mservice.Type, handler string) string {
return path.Join(er.apiEndpoint, service, handler)
}
func (er *HttpEndpointRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
ep := er.endpoint(service, endpoint)
hm := er.chooseMethod(method)
hndlr := func(w http.ResponseWriter, r *http.Request) {
res := handler(r)
res(w, r)
}
hm(er.router, ep, hndlr)
er.logger.Info("Handler installed", zap.String("endpoint", ep), zap.String("method", api.HTTPMethod2String(method)))
}

View File

@@ -0,0 +1,20 @@
package routers
import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
)
func (er *HttpEndpointRouter) CreateAccessToken(user *model.Account) (sresponse.TokenData, error) {
ja := jwtauth.New(er.signature.Algorithm, er.signature.PrivateKey, er.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2Claims(user, er.config.Expiration.Account))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(er.config.Expiration.Account) * time.Hour),
}
return token, err
}

View File

@@ -0,0 +1,40 @@
package routers
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/metrics"
"github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
)
type metricsRouter struct {
logger mlogger.Logger
handler http.Handler
}
func (mr *metricsRouter) Finish() {
mr.logger.Debug("Stopped")
}
func (mr *metricsRouter) handle(w http.ResponseWriter, r *http.Request) {
mr.logger.Debug("Serving metrics request...")
mr.handler.ServeHTTP(w, r)
}
func newMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) *metricsRouter {
mr := metricsRouter{
logger: logger.Named("metrics"),
handler: metrics.Handler(),
}
logger.Debug("Installing Prometheus middleware...")
router.Group(func(r chi.Router) {
ep := endpoint + "/metrics"
r.Get(ep, mr.handle)
logger.Info("Prometheus handler installed", zap.String("endpoint", ep))
})
return &mr
}

View File

@@ -0,0 +1,14 @@
package routers
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
)
type Metrics interface {
Finish()
}
func NewMetricsRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Metrics, error) {
return newMetricsRouter(logger, router, endpoint), nil
}

View File

@@ -0,0 +1,63 @@
package routers
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/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"go.uber.org/zap"
)
func (pr *PublicRouter) logUserIn(ctx context.Context, r *http.Request, req *srequest.Login) http.HandlerFunc {
// Get the account database entry
trimmedLogin := strings.TrimSpace(req.Login)
account, err := pr.db.GetByEmail(ctx, strings.ToLower(trimmedLogin))
if errors.Is(err, merrors.ErrNoData) || (account == nil) {
pr.logger.Debug("User not found while logging in", zap.Error(err), zap.String("login", req.Login))
return response.Unauthorized(pr.logger, pr.service, "user not found")
}
if err != nil {
pr.logger.Warn("Failed to query user with email", zap.Error(err), zap.String("login", req.Login))
return response.Internal(pr.logger, pr.service, err)
}
if account.VerifyToken != "" {
return response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
if !account.MatchPassword(req.Password) {
return response.Unauthorized(pr.logger, pr.service, "password does not match")
}
accessToken, err := pr.imp.CreateAccessToken(account)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(ctx, r, &req.SessionIdentifier, account, &accessToken)
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {
// TODO: add rate check
var req srequest.Login
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Info("Failed to decode login request", zap.Error(err))
return response.BadPayload(a.logger, mservice.Accounts, err)
}
req.Login = strings.TrimSpace(req.Login)
req.Password = strings.TrimSpace(req.Password)
if req.Login == "" {
return response.BadRequest(a.logger, mservice.Accounts, "email_missing", "login request has no user name")
}
if req.Password == "" {
return response.BadRequest(a.logger, mservice.Accounts, "password_missing", "login request has no password")
}
return a.logUserIn(r.Context(), r, &req)
}

View File

@@ -0,0 +1,29 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (pr *PublicRouter) refreshAccessToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing access token refresh request")
var req srequest.AccessTokenRefresh
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to process access token refreshment request", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return sresponse.Account(pr.logger, account, token)
}

View File

@@ -0,0 +1,77 @@
package routers
import (
"context"
"crypto/rand"
"encoding/base64"
"io"
"net/http"
"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"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.Internal("failed to generate secure random bytes: " + err.Error())
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func (er *PublicRouter) prepareRefreshToken(ctx context.Context, r *http.Request, session *model.SessionIdentifier, account *model.Account) (*model.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(er.config.Length)
if err != nil {
er.logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
token := &model.RefreshToken{
AccountBoundBase: model.AccountBoundBase{
AccountRef: account.GetID(),
},
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: *session,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(er.config.Expiration.Refresh) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: r.RemoteAddr,
}
if err = er.rtdb.Create(ctx, token); err != nil {
er.logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}
func (pr *PublicRouter) refreshAndRespondLogin(
ctx context.Context,
r *http.Request,
session *model.SessionIdentifier,
account *model.Account,
accessToken *sresponse.TokenData,
) http.HandlerFunc {
refreshToken, err := pr.prepareRefreshToken(ctx, r, session, account)
if err != nil {
pr.logger.Warn("Failed to create refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return response.Internal(pr.logger, pr.service, err)
}
token := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(pr.logger, account, accessToken, &token)
}

View File

@@ -0,0 +1,28 @@
package routers
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/srequest"
"go.uber.org/zap"
)
func (pr *PublicRouter) rotateRefreshToken(r *http.Request) http.HandlerFunc {
pr.logger.Debug("Processing token rotation request...")
var req srequest.TokenRefreshRotate
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
pr.logger.Info("Failed to decode token rotation request", zap.Error(err))
return response.BadPayload(pr.logger, mservice.RefreshTokens, err)
}
account, token, err := pr.validateRefreshToken(r.Context(), r, &req)
if err != nil {
pr.logger.Warn("Failed to validate refresh token", zap.Error(err))
return response.Auto(pr.logger, pr.service, err)
}
return pr.refreshAndRespondLogin(r.Context(), r, &req.SessionIdentifier, account, token)
}

View File

@@ -0,0 +1,46 @@
package routers
import (
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
signature middleware.Signature
service mservice.Type
}
func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc) {
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
logger: l,
db: db,
rtdb: rtdb,
config: *config,
signature: *signature,
imp: re.NewHttpEndpointRouter(l, apiEndpoint, router, config, signature),
service: mservice.Accounts,
}
hr.InstallHandler(hr.service, "/login", api.Post, hr.login)
hr.InstallHandler(hr.service, "/rotate", api.Post, hr.rotateRefreshToken)
hr.InstallHandler(hr.service, "/refresh", api.Post, hr.refreshAccessToken)
return &hr
}

View File

@@ -0,0 +1,59 @@
package routers
import (
"context"
"errors"
"net/http"
"time"
"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"
"go.uber.org/zap"
)
func validateToken(token string, rt *model.RefreshToken) string {
if rt.AccountRef == nil {
return "missing account reference"
}
if token != rt.RefreshToken {
return "tokens do not match"
}
if rt.ExpiresAt.Before(time.Now()) {
return "token expired"
}
if rt.IsRevoked {
return "token has been revoked"
}
return ""
}
func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Request, req *srequest.TokenRefreshRotate) (*model.Account, *sresponse.TokenData, error) {
rt, err := pr.rtdb.GetByCRT(ctx, req)
if errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("Refresh token not found", zap.String("client_id", req.ClientID), zap.String("device_id", req.DeviceID))
return nil, nil, err
}
if reason := validateToken(req.RefreshToken, rt); len(reason) > 0 {
pr.logger.Info("Token validation failed", zap.String("reason", reason))
return nil, nil, merrors.Unauthorized(reason)
}
var account model.Account
if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("User not found while rotating refresh token", zap.Error(err), mzap.ObjRef("account_ref", *rt.AccountRef))
return nil, nil, merrors.Unauthorized("user not found")
}
accessToken, err := pr.imp.CreateAccessToken(&account)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
return nil, nil, err
}
return &account, &accessToken, nil
}

View File

@@ -0,0 +1,15 @@
package routers
import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
)
type APIRouter interface {
InstallHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
}
type ProtectedAPIRouter interface {
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
}

View File

@@ -0,0 +1,68 @@
package ws
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
ac "github.com/tech/sendico/server/internal/api/config"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ac.WebSocketConfig, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}

View File

@@ -0,0 +1,15 @@
package ws
import (
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/server/interface/api/ws"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}

View File

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

View File

@@ -0,0 +1,35 @@
package flrstring
import (
"math/rand"
"time"
)
// Constants and variables for random string generation
const (
letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
letterIdxBits = 6
letterIdxMask = 1<<letterIdxBits - 1
letterIdxMax = 63 / letterIdxBits
)
var src = rand.NewSource(time.Now().UnixNano())
// createRandString creates a random string with the size of n
// See: http://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang
func CreateRandString(n int) string {
b := make([]byte, n)
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}

View File

@@ -0,0 +1,15 @@
package imagewriter
import (
"net/http"
"strconv"
)
func WriteImage(w http.ResponseWriter, buffer *[]byte, fileType string) error {
w.Header().Set("Content-Type", fileType)
w.Header().Set("Content-Length", strconv.Itoa(len(*buffer)))
w.WriteHeader(http.StatusOK)
_, err := w.Write(*buffer)
return err
}

View File

@@ -0,0 +1,39 @@
package mutil
import (
"fmt"
"strings"
)
func AddParam(base string, param string) string {
base = strings.TrimSuffix(base, "/")
return fmt.Sprintf("%s/{%s}", base, param)
}
func AddAccountRef(base string) string {
return AddParam(base, AccountRefName())
}
func AddObjRef(base string) string {
return AddParam(base, ObjRefName())
}
func AddOrganizaztionRef(base string) string {
return AddParam(base, OrganizationRefName())
}
func AddStatusRef(base string) string {
return AddParam(base, StatusRefName())
}
func AddProjectRef(base string) string {
return AddParam(base, ProjectRefName())
}
func AddInvitationRef(base string) string {
return AddParam(base, InvitationRefName())
}
func AddToken(base string) string {
return AddParam(base, TokenName())
}

View File

@@ -0,0 +1,135 @@
package mutil
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func GetParam(r *http.Request, paramName string) string {
return chi.URLParam(r, paramName)
}
func GetID(r *http.Request) string {
return GetParam(r, "id")
}
func GetAccountID(r *http.Request) string {
return GetParam(r, AccountRefName())
}
func GetObjRef(r *http.Request) string {
return GetParam(r, ObjRefName())
}
func GetOrganizationID(r *http.Request) string {
return GetParam(r, OrganizationRefName())
}
func GetOrganizationRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(GetOrganizationID(r))
}
func GetStatusID(r *http.Request) string {
return GetParam(r, OrganizationRefName())
}
func GetStatusRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(GetStatusID(r))
}
func GetProjectID(r *http.Request) string {
return GetParam(r, ProjectRefName())
}
func GetProjectRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(GetProjectID(r))
}
func GetInvitationID(r *http.Request) string {
return GetParam(r, InvitationRefName())
}
func GetInvitationRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(GetOrganizationID(r))
}
func GetToken(r *http.Request) string {
return GetParam(r, TokenName())
}
// parseFunc is a function type that parses a string to a specific type
type parseFunc[T any] func(string) (T, error)
// getOptionalParam is a generic function that handles optional query parameters
func GetOptionalParam[T any](logger mlogger.Logger, r *http.Request, key string, parse parseFunc[T]) (*T, error) {
vals := r.URL.Query()
s := vals.Get(key)
if s == "" {
return nil, nil
}
val, err := parse(s)
if err != nil {
logger.Debug("Malformed query parameter", zap.Error(err), zap.String(key, s))
return nil, err
}
return &val, nil
}
// getOptionalInt64Param gets an optional int64 query parameter
func GetOptionalInt64Param(logger mlogger.Logger, r *http.Request, key string) (*int64, error) {
return GetOptionalParam(logger, r, key, func(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
})
}
func GetLimit(logger mlogger.Logger, r *http.Request) (*int64, error) {
return GetOptionalInt64Param(logger, r, "limit")
}
func GetOffset(logger mlogger.Logger, r *http.Request) (*int64, error) {
return GetOptionalInt64Param(logger, r, "offset")
}
func GetLimitAndOffset(logger mlogger.Logger, r *http.Request) (*int64, *int64, error) {
limit, err := GetLimit(logger, r)
if err != nil {
return nil, nil, err
}
offset, err := GetOffset(logger, r)
if err != nil {
return nil, nil, err
}
return limit, offset, nil
}
func GetOptionalBoolParam(logger mlogger.Logger, r *http.Request, key string) (*bool, error) {
return GetOptionalParam(logger, r, key, strconv.ParseBool)
}
func GetCascadeParam(logger mlogger.Logger, r *http.Request) (*bool, error) {
return GetOptionalBoolParam(logger, r, "cascade")
}
func GetArchiveParam(logger mlogger.Logger, r *http.Request) (*bool, error) {
return GetOptionalBoolParam(logger, r, "archived")
}
func GetViewCursor(logger mlogger.Logger, r *http.Request) (*model.ViewCursor, error) {
var res model.ViewCursor
var err error
if res.Limit, res.Offset, err = GetLimitAndOffset(logger, r); err != nil {
return nil, err
}
if res.IsArchived, err = GetArchiveParam(logger, r); err != nil {
return nil, err
}
return &res, nil
}

View File

@@ -0,0 +1,142 @@
package mutil
import (
"net/http"
"testing"
"github.com/tech/sendico/pkg/mlogger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestGetOptionalBoolParam(t *testing.T) {
logger := mlogger.Logger(zap.NewNop())
tests := []struct {
name string
query string
expected *bool
hasError bool
}{
{
name: "valid true",
query: "?param=true",
expected: boolPtr(true),
hasError: false,
},
{
name: "valid false",
query: "?param=false",
expected: boolPtr(false),
hasError: false,
},
{
name: "missing parameter",
query: "?other=value",
expected: nil,
hasError: false,
},
{
name: "invalid value",
query: "?param=invalid",
expected: nil,
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com"+tt.query, nil)
require.NoError(t, err)
result, err := GetOptionalBoolParam(logger, req, "param")
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
if tt.expected == nil {
assert.Nil(t, result)
} else {
assert.NotNil(t, result)
assert.Equal(t, *tt.expected, *result)
}
})
}
}
func TestGetOptionalInt64Param(t *testing.T) {
logger := mlogger.Logger(zap.NewNop())
tests := []struct {
name string
query string
expected *int64
hasError bool
}{
{
name: "valid positive number",
query: "?param=123",
expected: int64Ptr(123),
hasError: false,
},
{
name: "valid negative number",
query: "?param=-456",
expected: int64Ptr(-456),
hasError: false,
},
{
name: "valid zero",
query: "?param=0",
expected: int64Ptr(0),
hasError: false,
},
{
name: "missing parameter",
query: "?other=value",
expected: nil,
hasError: false,
},
{
name: "invalid value",
query: "?param=invalid",
expected: nil,
hasError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com"+tt.query, nil)
require.NoError(t, err)
result, err := GetOptionalInt64Param(logger, req, "param")
if tt.hasError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
if tt.expected == nil {
assert.Nil(t, result)
} else {
assert.NotNil(t, result)
assert.Equal(t, *tt.expected, *result)
}
})
}
}
// Helper functions for creating pointers to values
func boolPtr(b bool) *bool {
return &b
}
func int64Ptr(i int64) *int64 {
return &i
}

View File

@@ -0,0 +1,44 @@
package mutil
import (
"net/http"
mutilimp "github.com/tech/sendico/server/internal/mutil/param/internal"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type ParamHelper interface {
Name() string
RefName() string
GetID(r *http.Request) string
GetRef(r *http.Request) (primitive.ObjectID, error)
AddRef(base string) string
}
func CreatePH(resource string) ParamHelper {
return mutilimp.CreateImp(resource)
}
type DependentParamHelper struct {
p ParamHelper
c ParamHelper
}
func (ph *DependentParamHelper) Parent() ParamHelper {
return ph.p
}
func (ph *DependentParamHelper) Child() ParamHelper {
return ph.c
}
func (ph *DependentParamHelper) AddRef(base string) string {
return ph.Parent().AddRef(ph.Child().AddRef(base))
}
func CreateDPH(pRes, cRes string) *DependentParamHelper {
return &DependentParamHelper{
p: CreatePH(pRes),
c: CreatePH(cRes),
}
}

View File

@@ -0,0 +1,51 @@
package mutilimp
import (
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func addParam(base string, param string) string {
base = strings.TrimSuffix(base, "/")
return fmt.Sprintf("%s/{%s}", base, param)
}
func _ref(param string) string {
return param + "_ref"
}
func getParam(r *http.Request, paramName string) string {
return chi.URLParam(r, paramName)
}
type ParamHelper struct {
t string
}
func (ph *ParamHelper) Name() string {
return ph.t
}
func (ph *ParamHelper) RefName() string {
return _ref(ph.Name())
}
func (ph *ParamHelper) GetID(r *http.Request) string {
return getParam(r, ph.RefName())
}
func (ph *ParamHelper) GetRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(ph.GetID(r))
}
func (ph *ParamHelper) AddRef(base string) string {
return addParam(base, ph.RefName())
}
func CreateImp(resource string) *ParamHelper {
return &ParamHelper{t: resource}
}

View File

@@ -0,0 +1,15 @@
package mutil
import (
"net/http"
"go.uber.org/zap"
)
func PLog(ph ParamHelper, r *http.Request) zap.Field {
return zap.String(ph.Name(), ph.GetID(r))
}
func PLogType(ph ParamHelper) zap.Field {
return zap.String("object", ph.Name())
}

View File

@@ -0,0 +1,33 @@
package mutil
func _ref(param string) string {
return param + "_ref"
}
func AccountRefName() string {
return _ref("account")
}
func ObjRefName() string {
return _ref("obj")
}
func OrganizationRefName() string {
return _ref("org")
}
func StatusRefName() string {
return _ref("status")
}
func ProjectRefName() string {
return _ref("project")
}
func InvitationRefName() string {
return _ref("invitation")
}
func TokenName() string {
return "token"
}

View File

@@ -0,0 +1,11 @@
package mutil
import (
"net/http"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func GetAccountRef(r *http.Request) (primitive.ObjectID, error) {
return primitive.ObjectIDFromHex(GetAccountID(r))
}

View File

@@ -0,0 +1,15 @@
package mutil
import "time"
func ToDate(t time.Time) string {
return t.Format(time.DateOnly)
}
func ToTime(t time.Time) string {
return t.Format(time.TimeOnly)
}
func ToDateTime(t time.Time) string {
return t.Format(time.DateTime)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
package aapitemplate
import (
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
model "github.com/tech/sendico/pkg/model/notification"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// NotificationHandler is a function that processes an object of type T and returns an error.
type NotificationHandler[T any] func(template T, actorAccountRef primitive.ObjectID) error
// sinkNotification is the default no-op strategy.
func sinkNotification[T any](_ T, _ primitive.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 primitive.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 primitive.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
nc.factory = factory
// Build sending functions for each notification type.
nc.CreateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
}
nc.UpdateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
}
nc.ArchiveNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
}
nc.NeedArchiveNotification = true
nc.DeleteNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
}
nc.NeedDeleteNotification = true
return nc
}
// WithNoCreateNotification disables the create notification.
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
nc.CreateNotification = sinkNotification[T]
return nc
}
// WithNoUpdateNotification disables the update notification.
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
nc.UpdateNotification = sinkNotification[T]
return nc
}
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
nc.ArchiveNotification = sinkNotification[T]
return nc
}
// WithNoDeleteNotification disables the delete notification.
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
nc.DeleteNotification = sinkNotification[T]
nc.NeedDeleteNotification = false
return nc
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,181 @@
package aapitemplate
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
model "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"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 primitive.ObjectID, t model.NotificationAction) notifications.Envelope) *AccountAPI[T] {
a.nconfig.WithNotifications(factory)
a.Logger.Info("Notificatons handler installed")
return a
}
// WithNoCreateNotification disables the create notification.
func (a *AccountAPI[T]) WithNoCreateNotification() *AccountAPI[T] {
a.nconfig.WithNoCreateNotification()
a.Logger.Info("Object creation notificaton disabled")
return a
}
// WithNoUpdateNotification disables the update notification.
func (a *AccountAPI[T]) WithNoUpdateNotification() *AccountAPI[T] {
a.nconfig.WithNoUpdateNotification()
a.Logger.Info("Object update notificaton disabled")
return a
}
// WithNoDeleteNotification disables the delete notification.
func (a *AccountAPI[T]) WithNoDeleteNotification() *AccountAPI[T] {
a.nconfig.WithNoDeleteNotification()
a.Logger.Info("Object deletion notificaton disabled")
return a
}
func (a *AccountAPI[T]) WithNoCreate() *AccountAPI[T] {
a.config.WithNoCreate()
a.Logger.Info("Create handler disabled")
return a
}
func (a *AccountAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithCreateHandler(handler)
a.Logger.Info("Create handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoList() *AccountAPI[T] {
a.config.WithNoList()
a.Logger.Info("List handler disabled")
return a
}
func (a *AccountAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithListHandler(handler)
a.Logger.Info("List handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoGet() *AccountAPI[T] {
a.config.WithNoGet()
a.Logger.Info("Get handler disabled")
return a
}
func (a *AccountAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithGetHandler(handler)
a.Logger.Info("Get handler overridden")
return a
}
func (a *AccountAPI[T]) WithReorderHandler(reorder ReorderConfig) *AccountAPI[T] {
a.config.WithReorderHandler(reorder)
a.Logger.Info("Reorder handler installed")
return a
}
func (a *AccountAPI[T]) WithNoUpdate() *AccountAPI[T] {
a.config.WithNoUpdate()
a.Logger.Info("Update handler disabled")
return a
}
func (a *AccountAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithUpdateHandler(handler)
a.Logger.Info("Update handler overridden")
return a
}
func (a *AccountAPI[T]) WithNoDelete() *AccountAPI[T] {
a.config.WithNoDelete()
a.Logger.Info("Delete handler disabled")
return a
}
func (a *AccountAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *AccountAPI[T] {
a.config.WithDeleteHandler(handler)
a.Logger.Info("Delete handler overriden")
return a
}
func CreateAPI[T any](a eapi.API, dbFactory func() (DB[T], error), resource mservice.Type) (*AccountAPI[T], error) {
p := &AccountAPI[T]{
Logger: a.Logger().Named(resource),
Oph: mutil.CreatePH("obj"), // to avoid collision with object_ref
Orgph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref
a: a,
config: NewConfig(),
resource: resource,
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
}
var err error
if p.DB, err = dbFactory(); err != nil {
p.Logger.Error("Failed to create protected database", zap.Error(err))
return nil, err
}
return p, nil
}

View File

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

View File

@@ -0,0 +1,103 @@
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) reportTokenNotFound() http.HandlerFunc {
return response.NotFound(a.logger, a.Name(), "No account found associated with given verifcation token")
}
func (a *AccountAPI) sendWelcomeEmail(account *model.Account) error {
if err := a.producer.SendMessage(an.AccountCreated(a.Name(), *account.GetID())); 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)
}
// Get the account
// accnt, err := a.db.GetByEmail(ctx, paramGetter(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)
// }
accnt, err := paramGetter(r.Context(), 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)
}
if accnt.VerifyToken == "" {
a.logger.Debug("Verification token is empty", zap.Error(err), mzap.StorableRef(u))
return a.reportTokenNotFound()
}
// Send welcome email
if err = a.sendWelcomeEmail(accnt); err != nil {
a.logger.Warn("Failed to send verification email",
zap.Error(err), mzap.StorableRef(u), zap.String("email", accnt.Login))
return response.Internal(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}
func getID(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
var res model.Account
return &res, db.Get(ctx, *u.GetID(), &res)
}
func getEmail(ctx context.Context, db account.DB, u *model.Account) (*model.Account, error) {
return db.GetByEmail(ctx, u.Login)
}
func (a *AccountAPI) reportNoEmailRegistered() http.HandlerFunc {
return response.BadRequest(a.logger, a.Name(), "email_not_registered", "no account registered with this email")
}

View File

@@ -0,0 +1,123 @@
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/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
"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, primitive.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.accService.DeleteAll(ctx, &org, account.ID); err != nil {
a.logger.Error("Failed to delete all data", 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) (primitive.ObjectID, error) {
return a.oph.GetRef(r)
}

View File

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

View File

@@ -0,0 +1,45 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"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()
user, err := a.db.GetByToken(ctx, token)
if errors.Is(err, merrors.ErrNoData) {
a.logger.Debug("Verification token not found", zap.Error(err))
return a.reportTokenNotFound()
}
if err != nil {
a.logger.Warn("Failed to fetch account", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
// Delete verification token to confirm account
user.VerifyToken = ""
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)
}
// TODO: Send verification confirmation email
return response.Success(a.logger)
}
func (a *AccountAPI) resendVerificationMail(r *http.Request) http.HandlerFunc {
return a.sendVerificationMail(r, getID)
}
func (a *AccountAPI) resendVerification(r *http.Request) http.HandlerFunc {
return a.sendVerificationMail(r, getEmail)
}

View File

@@ -0,0 +1,43 @@
package accountapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
"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, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check accounts access permissions", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading organization employees", mzap.StorableRef(account))
return response.AccessDenied(a.logger, a.Name(), "orgnizations employees read permission denied")
}
var org model.Organization
if err := a.odb.Get(ctx, *account.GetID(), orgRef, &org); err != nil {
a.logger.Warn("Failed to fetch organization", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
emps, err := a.db.GetAccountsByRefs(ctx, orgRef, org.Members)
if err != nil {
a.logger.Warn("Failed to fetch organization emplpyees", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.Accounts(a.logger, emps, orgRef, token)
}

View File

@@ -0,0 +1,82 @@
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
if err := a.accService.UpdateLogin(ctx, &acc, u.Login); 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); err != nil {
a.logger.Warn("Failed to send verification email", zap.Error(err), mzap.StorableRef(&acc))
return response.Internal(a.logger, a.Name(), err)
}
} else {
// Save the user
acc.AccountPublic = u
if err = a.db.Update(ctx, &acc); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(&acc))
return response.Internal(a.logger, a.Name(), err)
}
}
return sresponse.Account(a.logger, &acc, token)
}

View File

@@ -0,0 +1,196 @@
package accountapiimp
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.uber.org/zap"
)
func (a *AccountAPI) checkPassword(_ *http.Request, account *model.Account, accessToken *sresponse.TokenData) http.HandlerFunc {
return sresponse.Account(a.logger, account, accessToken)
}
func (a *AccountAPI) changePassword(r *http.Request, user *model.Account, token *sresponse.TokenData) http.HandlerFunc {
// TODO: add rate check
var pcr srequest.ChangePassword
if err := json.NewDecoder(r.Body).Decode(&pcr); err != nil {
a.logger.Warn("Failed to decode password change request", zap.Error(err), mzap.StorableRef(user))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := a.accService.ValidatePassword(pcr.New, &pcr.Old); err != nil {
a.logger.Debug("Password validation failed", zap.Error(err), mzap.StorableRef(user))
return sresponse.BadRPassword(a.logger, a.Name(), err)
}
ctx := r.Context()
if !user.MatchPassword(pcr.Old) {
a.logger.Info("Old password does not match", mzap.StorableRef(user))
return a.reportUnauthorized("old password does not match")
}
user.Password = pcr.New
if err := user.HashPassword(); err != nil {
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.db.Update(ctx, user); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(user))
return response.Internal(a.logger, a.Name(), err)
}
if err := a.rtdb.RevokeAll(ctx, *user.GetID(), pcr.DeviceID); err != nil {
a.logger.Warn("Failed to revoke refresh tokens",
zap.Error(err), mzap.StorableRef(user), zap.String("device_id", pcr.DeviceID))
}
return sresponse.Account(a.logger, user, token)
}
func (a *AccountAPI) forgotPassword(r *http.Request) http.HandlerFunc {
var req srequest.ForgotPassword
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode password change request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
if req.Login == "" {
a.logger.Debug("Email is missing in the request")
return a.reportEmailMissing()
}
// Always use the lower case email address
req.Login = strings.ToLower(req.Login)
// Get user
ctx := r.Context()
user, err := a.db.GetByEmail(ctx, req.Login)
if (errors.Is(err, merrors.ErrNoData)) || (user == nil) {
a.logger.Debug("User not found while recovering password", zap.Error(err), zap.String("email", req.Login))
return a.reportNoEmailRegistered()
}
if err != nil {
a.logger.Warn("Failed to fetch user", zap.Error(err), zap.String("email", req.Login))
return response.Auto(a.logger, a.Name(), err)
}
// Generate reset password token
if err := a.accService.ResetPassword(ctx, user); 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, user.ResetPasswordToken); 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)
}
// Validate reset token
if user.ResetPasswordToken == "" {
a.logger.Debug("No reset token found for user", mzap.StorableRef(&user))
return response.BadRequest(a.logger, a.Name(), "no_reset_token", "No password reset token found for this user")
}
if user.ResetPasswordToken != token {
a.logger.Debug("Reset token mismatch", mzap.StorableRef(&user))
return response.BadRequest(a.logger, a.Name(), "invalid_token", "Invalid or expired reset token")
}
// 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)
}
if req.Password == "" {
a.logger.Debug("New password is empty")
return response.BadRequest(a.logger, a.Name(), "empty_password", "New password cannot be empty")
}
// 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
user.ResetPasswordToken = "" // Clear the token after use
// Hash the new password
if err := user.HashPassword(); err != nil {
a.logger.Warn("Failed to hash new password", zap.Error(err), mzap.StorableRef(user))
return nil, err
}
// Save the updated user
if err := a.db.Update(ctx, user); err != nil {
a.logger.Warn("Failed to save user with new password", zap.Error(err), mzap.StorableRef(user))
return nil, err
}
// Revoke all refresh tokens for this user (force re-login)
if err := a.rtdb.RevokeAll(ctx, user.ID, ""); err != nil {
a.logger.Warn("Failed to revoke refresh tokens after password reset", zap.Error(err), mzap.StorableRef(user))
// Don't fail the transaction if token revocation fails, but log it
}
return nil, nil
}

View File

@@ -0,0 +1,361 @@
package accountapiimp
import (
"testing"
"github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// TestPasswordResetTokenGeneration tests the token generation logic
func TestPasswordResetTokenGeneration(t *testing.T) {
// Test that ResetPassword service method generates a token
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
}
// Initially no reset token
assert.Empty(t, account.ResetPasswordToken, "Account should not have reset token initially")
// Simulate what ResetPassword service method does
account.ResetPasswordToken = "generated-token-123"
assert.NotEmpty(t, account.ResetPasswordToken, "Reset token should be generated")
assert.Equal(t, "generated-token-123", account.ResetPasswordToken, "Reset token should match generated value")
}
// TestPasswordResetTokenValidation tests token validation logic
func TestPasswordResetTokenValidation(t *testing.T) {
tests := []struct {
name string
storedToken string
providedToken string
shouldBeValid bool
}{
{
name: "ValidToken_ShouldMatch",
storedToken: "valid-token-123",
providedToken: "valid-token-123",
shouldBeValid: true,
},
{
name: "InvalidToken_ShouldNotMatch",
storedToken: "valid-token-123",
providedToken: "invalid-token-456",
shouldBeValid: false,
},
{
name: "EmptyStoredToken_ShouldBeInvalid",
storedToken: "",
providedToken: "any-token",
shouldBeValid: false,
},
{
name: "EmptyProvidedToken_ShouldBeInvalid",
storedToken: "valid-token-123",
providedToken: "",
shouldBeValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: model.Describable{
Name: "Test User",
},
},
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
},
ResetPasswordToken: tt.storedToken,
}
// Test token validation logic (what the resetPassword handler does)
isValid := account.ResetPasswordToken != "" && account.ResetPasswordToken == tt.providedToken
assert.Equal(t, tt.shouldBeValid, isValid, "Token validation should match expected result")
})
}
}
// 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 := primitive.NewObjectID()
assert.NotEqual(t, primitive.NilObjectID, userID, "User ID should be valid")
// Step 4: System validates token and updates password
storedToken := resetToken
providedToken := resetToken
tokenValid := storedToken == providedToken
assert.True(t, tokenValid, "Token should be valid")
// Step 5: Password gets updated and token cleared
oldPassword := "old-password"
newPassword := "new-password-123!"
clearedToken := ""
assert.NotEqual(t, oldPassword, newPassword, "Password should be changed")
assert.Empty(t, clearedToken, "Token should be cleared after use")
assert.NotEqual(t, storedToken, clearedToken, "Token should be different after clearing")
})
t.Run("TokenSecurity", func(t *testing.T) {
// Test that tokens are single-use
originalToken := "valid-token-123"
usedToken := "" // After use, token should be cleared
assert.NotEmpty(t, originalToken, "Original token should exist")
assert.Empty(t, usedToken, "Used token should be cleared")
assert.NotEqual(t, originalToken, usedToken, "Token should be cleared after use")
// Test that different tokens are not equal
token1 := "token-123"
token2 := "token-456"
assert.NotEqual(t, token1, token2, "Different tokens should not be equal")
})
}
// TestPasswordValidationLogic tests password complexity requirements
func TestPasswordValidationLogic(t *testing.T) {
t.Run("ValidPasswords", func(t *testing.T) {
validPasswords := []string{
"Password123!",
"MySecurePass1@",
"ComplexP@ssw0rd",
}
for _, password := range validPasswords {
t.Run(password, func(t *testing.T) {
// Test minimum length
assert.True(t, len(password) >= 8, "Password should be at least 8 characters")
// Test for at least one digit
hasDigit := false
for _, char := range password {
if char >= '0' && char <= '9' {
hasDigit = true
break
}
}
assert.True(t, hasDigit, "Password should contain at least one digit")
// Test for at least one uppercase letter
hasUpper := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
break
}
}
assert.True(t, hasUpper, "Password should contain at least one uppercase letter")
// Test for at least one lowercase letter
hasLower := false
for _, char := range password {
if char >= 'a' && char <= 'z' {
hasLower = true
break
}
}
assert.True(t, hasLower, "Password should contain at least one lowercase letter")
// Test for at least one special character
hasSpecial := false
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
for _, char := range password {
for _, special := range specialChars {
if char == special {
hasSpecial = true
break
}
}
if hasSpecial {
break
}
}
assert.True(t, hasSpecial, "Password should contain at least one special character")
})
}
})
t.Run("InvalidPasswords", func(t *testing.T) {
invalidPasswords := []string{
"", // Empty
"short", // Too short
"nouppercase1!", // No uppercase
"NOLOWERCASE1!", // No lowercase
"NoNumbers!", // No numbers
"NoSpecial1", // No special characters
}
for _, password := range invalidPasswords {
t.Run(password, func(t *testing.T) {
// Test that invalid passwords fail at least one requirement
isValid := true
// Check length
if len(password) < 8 {
isValid = false
}
// Check for digit
hasDigit := false
for _, char := range password {
if char >= '0' && char <= '9' {
hasDigit = true
break
}
}
if !hasDigit {
isValid = false
}
// Check for uppercase
hasUpper := false
for _, char := range password {
if char >= 'A' && char <= 'Z' {
hasUpper = true
break
}
}
if !hasUpper {
isValid = false
}
// Check for lowercase
hasLower := false
for _, char := range password {
if char >= 'a' && char <= 'z' {
hasLower = true
break
}
}
if !hasLower {
isValid = false
}
// Check for special character
hasSpecial := false
specialChars := "!@#$%^&*()_+-=[]{}|;:,.<>?"
for _, char := range password {
for _, special := range specialChars {
if char == special {
hasSpecial = true
break
}
}
if hasSpecial {
break
}
}
if !hasSpecial {
isValid = false
}
assert.False(t, isValid, "Invalid password should fail validation")
})
}
})
}
// TestEmailValidationLogic tests email format validation
func TestEmailValidationLogic(t *testing.T) {
t.Run("ValidEmails", func(t *testing.T) {
validEmails := []string{
"test@example.com",
"user.name@domain.org",
"user+tag@example.co.uk",
"test123@domain.com",
}
for _, email := range validEmails {
t.Run(email, func(t *testing.T) {
// Basic email validation logic
hasAt := false
hasDot := false
atIndex := -1
dotIndex := -1
for i, char := range email {
if char == '@' {
hasAt = true
atIndex = i
}
if char == '.' {
hasDot = true
dotIndex = i
}
}
assert.True(t, hasAt, "Valid email should contain @")
assert.True(t, hasDot, "Valid email should contain .")
assert.True(t, atIndex > 0, "Valid email should have @ not at start")
assert.True(t, dotIndex > atIndex, "Valid email should have . after @")
assert.True(t, len(email) > atIndex+1, "Valid email should have domain after @")
})
}
})
t.Run("InvalidEmails", func(t *testing.T) {
invalidEmails := []string{
"", // Empty
"noat.com", // No @
"test@nodot", // No .
"@nodomain.com", // No local part
"test@.com", // No domain
"test.com@", // No domain after @
}
for _, email := range invalidEmails {
t.Run(email, func(t *testing.T) {
// Basic email validation logic
hasAt := false
hasDot := false
atIndex := -1
dotIndex := -1
for i, char := range email {
if char == '@' {
hasAt = true
atIndex = i
}
if char == '.' {
hasDot = true
dotIndex = i
}
}
// Invalid emails should fail at least one requirement
domainAfterDot := len(email) > dotIndex+1
domainAfterAt := len(email) > atIndex+1
isValid := hasAt && hasDot && atIndex > 0 && dotIndex > atIndex && domainAfterAt && domainAfterDot && (dotIndex-atIndex) > 1
assert.False(t, isValid, "Invalid email should fail validation")
})
}
})
}

View File

@@ -0,0 +1,124 @@
package accountapiimp
import (
"context"
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/domainprovider"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"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/bson/primitive"
"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
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 primitive.ObjectID
accService accountservice.AccountService
}
func (a *AccountAPI) Name() mservice.Type {
return mservice.Accounts
}
func (a *AccountAPI) Finish(ctx context.Context) error {
return a.avatars.Finish(ctx)
}
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
}
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().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
return p, nil
}

View File

@@ -0,0 +1,176 @@
package accountapiimp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"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/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *AccountAPI) createAnonymousAccount(ctx context.Context, org *model.Organization, sr *srequest.Signup) error {
anonymousUser := &model.Account{
AccountPublic: model.AccountPublic{
AccountBase: model.AccountBase{
Describable: sr.AnonymousUser,
},
UserDataBase: sr.Account.UserDataBase,
},
}
r, err := a.pmanager.Role().Create(ctx, org.ID, &sr.AnonymousRole)
if err != nil {
a.logger.Warn("Failed to create anonymous role", zap.Error(err))
return err
}
if err := a.accService.CreateAccount(ctx, org, anonymousUser, r.ID); err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", anonymousUser.Login))
return err
}
return nil
}
func (a *AccountAPI) createOrg(ctx context.Context, sr *srequest.Signup, permissionRef primitive.ObjectID) (*model.Organization, error) {
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()))
}
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
PermissionBound: model.PermissionBound{
PermissionRef: permissionRef,
},
Describable: model.Describable{
Name: sr.OrganizationName,
},
TimeZone: sr.OrganizationTimeZone,
},
Members: []primitive.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.BadRequest(a.logger, a.Name(), "", err.Error())
}
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)
}
if err := a.executeSignupTransaction(r.Context(), &sr, newAccount); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
a.logger.Info("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.Info("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); err != nil {
a.logger.Warn("Failed to send welcome email", zap.Error(err), mzap.StorableRef(newAccount))
}
return sresponse.SignUp(a.logger, newAccount)
}
func (a *AccountAPI) executeSignupTransaction(ctxt context.Context, sr *srequest.Signup, newAccount *model.Account) error {
_, err := a.tf.CreateTransaction().Execute(ctxt, func(ctx context.Context) (any, error) {
return a.signupTransactionBody(ctx, sr, newAccount)
})
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
}
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
}
if err := a.grantAllPermissions(ctx, org.ID, roleDescription.ID, newAccount); err != nil {
return nil, err
}
if err := a.accService.CreateAccount(ctx, org, newAccount, roleDescription.ID); err != nil {
a.logger.Warn("Failed to create account", zap.Error(err), zap.String("login", newAccount.Login))
return nil, err
}
if err := a.createAnonymousAccount(ctx, org, sr); err != nil {
return nil, err
}
return nil, nil
}
func (a *AccountAPI) grantAllPermissions(ctx context.Context, organizationRef primitive.ObjectID, roleID primitive.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
}
actions := []model.Action{model.ActionCreate, model.ActionRead, model.ActionUpdate, model.ActionDelete}
for _, policy := range policies {
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
}
return nil
}

View File

@@ -0,0 +1,249 @@
package accountapiimp_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
// TestSignupRequestSerialization tests JSON marshaling/unmarshaling with real MongoDB
func TestSignupRequestSerialization(t *testing.T) {
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(ctx, 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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// 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.OrganizationName, retrieved.OrganizationName)
assert.Equal(t, signupRequest.OrganizationTimeZone, retrieved.OrganizationTimeZone)
assert.Equal(t, len(signupRequest.DefaultPriorityGroup.Priorities), len(retrieved.DefaultPriorityGroup.Priorities))
// Verify priorities
for i, priority := range signupRequest.DefaultPriorityGroup.Priorities {
assert.Equal(t, priority.Name, retrieved.DefaultPriorityGroup.Priorities[i].Name)
if priority.Color != nil && retrieved.DefaultPriorityGroup.Priorities[i].Color != nil {
assert.Equal(t, *priority.Color, *retrieved.DefaultPriorityGroup.Priorities[i].Color)
}
}
})
}
// 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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
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.OrganizationName, parsedRequest.OrganizationName)
})
t.Run("UnicodeCharacters", func(t *testing.T) {
unicodeRequest := signupRequest
unicodeRequest.Account.Name = "Test 用户 Üser"
unicodeRequest.OrganizationName = "测试 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.OrganizationName)
})
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!",
},
Name: "Test User",
}
t.Run("ToAccount", func(t *testing.T) {
account := accountData.ToAccount()
assert.Equal(t, accountData.Login, account.Login)
assert.Equal(t, accountData.Password, account.Password)
assert.Equal(t, accountData.Name, account.Name)
// Verify the account has proper structure
assert.NotNil(t, account)
assert.IsType(t, &model.Account{}, account)
})
t.Run("PasswordHandling", func(t *testing.T) {
account := accountData.ToAccount()
// Original password should be preserved before validation
assert.Equal(t, "TestPassword123!", account.Password)
// Verify password is not empty
assert.NotEmpty(t, account.Password)
})
}

View File

@@ -0,0 +1,311 @@
package accountapiimp
import (
"testing"
"time"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/stretchr/testify/assert"
)
// Helper function to create string pointers
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!",
},
Name: "Test User",
},
OrganizationName: "Test Organization",
OrganizationTimeZone: "UTC",
DefaultPriorityGroup: srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Default Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
},
AnonymousUser: model.Describable{
Name: "Anonymous User",
},
OwnerRole: model.Describable{
Name: "Owner",
},
AnonymousRole: model.Describable{
Name: "Anonymous",
},
}
// 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.OrganizationName)
assert.Equal(t, "UTC", request.OrganizationTimeZone)
assert.Equal(t, "Default Priority Group", request.DefaultPriorityGroup.Description.Name)
assert.Len(t, request.DefaultPriorityGroup.Priorities, 3)
assert.Equal(t, "High", request.DefaultPriorityGroup.Priorities[0].Name)
assert.Equal(t, "#FF0000", *request.DefaultPriorityGroup.Priorities[0].Color)
}
// TestSignupRequestValidation tests various signup request validation scenarios
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!",
},
Name: "Test User",
},
OrganizationName: "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.OrganizationName)
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)
}
}
})
}
})
}
// TestPriorityGroupCreation tests the priority group structure
func TestPriorityGroupCreation(t *testing.T) {
priorityGroup := srequest.CreatePriorityGroup{
Description: model.Describable{
Name: "Test Priority Group",
},
Priorities: []model.Colorable{
{
Describable: model.Describable{Name: "Critical"},
Color: stringPtr("#FF0000"),
},
{
Describable: model.Describable{Name: "High"},
Color: stringPtr("#FF8000"),
},
{
Describable: model.Describable{Name: "Medium"},
Color: stringPtr("#FFFF00"),
},
{
Describable: model.Describable{Name: "Low"},
Color: stringPtr("#00FF00"),
},
},
}
assert.Equal(t, "Test Priority Group", priorityGroup.Description.Name)
assert.Len(t, priorityGroup.Priorities, 4)
// Test each priority
expectedPriorities := []struct {
name string
color string
}{
{"Critical", "#FF0000"},
{"High", "#FF8000"},
{"Medium", "#FFFF00"},
{"Low", "#00FF00"},
}
for i, expected := range expectedPriorities {
assert.Equal(t, expected.name, priorityGroup.Priorities[i].Name)
assert.Equal(t, expected.color, *priorityGroup.Priorities[i].Color)
}
}
// TestAccountDataToAccount tests the ToAccount method
func TestAccountDataToAccount(t *testing.T) {
accountData := model.AccountData{
LoginData: model.LoginData{
UserDataBase: model.UserDataBase{
Login: "test@example.com",
},
Password: "TestPassword123!",
},
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 #")
})
}
}

View File

@@ -0,0 +1,61 @@
package accountapiimp
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
"go.uber.org/zap"
)
func (a *AccountAPI) updateProfile(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
ctx := r.Context()
// Validate user input
u, err := a.attemptDecodeAccount(r)
if err != nil {
a.logger.Warn("Failed to decide profile update", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
if u.Login == "" {
a.logger.Debug("No email in request")
return a.reportEmailMissing()
}
if u.Name == "" {
a.logger.Debug("No name in request")
return response.BadRequest(a.logger, a.Name(), "name_missing", "name is required")
}
if account.Login != u.Login {
// Change email address
if err := a.accService.UpdateLogin(ctx, account, u.Login); 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); 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.ResetPasswordToken = account.ResetPasswordToken
u.VerifyToken = account.VerifyToken
if err = a.db.Update(ctx, u); err != nil {
a.logger.Warn("Failed to save account", zap.Error(err), mzap.StorableRef(u))
return response.Internal(a.logger, a.Name(), err)
}
}
return sresponse.Account(a.logger, u, token)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
package invitationimp
import (
"context"
"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/srequest"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *InvitationAPI) doAccept(ctx context.Context, invitationRef primitive.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) getPendingInvitation(ctx context.Context, invitationRef primitive.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 primitive.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
if err := a.accService.CreateAccount(ctx, org, account, inv.RoleRef); err != nil {
a.Logger.Warn("Failed to create account", zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
}
return account, nil
} else if err != nil {
a.Logger.Warn("Failed to fetch account by email", zap.Error(err), zap.String("email", inv.Content.Email))
return nil, err
} else {
// If account already exists, then just join organization
if err := a.accService.JoinOrganization(ctx, org, account, inv.RoleRef); err != nil {
a.Logger.Warn("Failed to join organization", zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return nil, err
}
}
return account, nil
}
func (a *InvitationAPI) accept(r *http.Request) http.HandlerFunc {
invitationRef, err := a.irh.GetRef(r)
if err != nil {
return a.respondBadReference(r, err)
}
var req srequest.AcceptInvitation
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.Logger.Warn("Failed to decode request body", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.BadPayload(a.Logger, a.Name(), err)
}
if _, err := a.tf.CreateTransaction().Execute(r.Context(), func(ctx context.Context) (any, error) {
return nil, a.doAccept(ctx, invitationRef, req.Account)
}); err != nil {
a.Logger.Warn("Failed to accept invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
return response.Auto(a.Logger, a.Name(), err)
}
return response.Success(a.Logger)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
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/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
}
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(),
}
p, err := papitemplate.CreateAPI(a, dbFactory, mservice.Organizations, mservice.Invitations)
if err != nil {
return nil, err
}
res.ProtectedAPI = *p.WithNotifications(res.notification).Build()
if res.db, err = a.DBFactory().NewInvitationsDB(); err != nil {
res.Logger.Warn("Failed to create invitation database", zap.Error(err))
return nil, err
}
if res.adb, err = a.DBFactory().NewAccountDB(); err != nil {
res.Logger.Warn("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.odb, err = a.DBFactory().NewOrganizationDB(); err != nil {
res.Logger.Warn("Failed to create organizations database", zap.Error(err))
return nil, err
}
if res.accService, err = accountservice.NewAccountService(
res.Logger,
a.DBFactory(),
a.Permissions().Enforcer(),
a.Permissions().Manager().Role(),
&a.Config().Mw.Password); err != nil {
res.Logger.Warn("Failed to create account service", zap.Error(err))
return nil, err
}
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/public"), api.Get, res.public)
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/accept"), api.Put, res.accept)
a.Register().Handler(mservice.Invitations, res.irh.AddRef("/decline"), api.Delete, res.decline)
return res, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
package papitemplate
import (
"github.com/tech/sendico/pkg/messaging"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
model "github.com/tech/sendico/pkg/model/notification"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// NotificationHandler is a function that processes an object of type T and returns an error.
type NotificationHandler[T any] func(template T, actorAccountRef primitive.ObjectID) error
// sinkNotification is the default no-op strategy.
func sinkNotification[T any](_ T, _ primitive.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 primitive.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 primitive.ObjectID, typ model.NotificationAction) notifications.Envelope) *NotificationConfig[T] {
nc.factory = factory
// Build sending functions for each notification type.
nc.CreateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NACreated))
}
nc.UpdateNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAUpdated))
}
nc.ArchiveNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NAArchived))
}
nc.NeedArchiveNotification = true
nc.DeleteNotification = func(template T, actorAccountRef primitive.ObjectID) error {
return nc.producer.SendMessage(factory(template, actorAccountRef, model.NADeleted))
}
nc.NeedDeleteNotification = true
return nc
}
// WithNoCreateNotification disables the create notification.
func (nc *NotificationConfig[T]) WithNoCreateNotification() *NotificationConfig[T] {
nc.CreateNotification = sinkNotification[T]
return nc
}
// WithNoUpdateNotification disables the update notification.
func (nc *NotificationConfig[T]) WithNoUpdateNotification() *NotificationConfig[T] {
nc.UpdateNotification = sinkNotification[T]
return nc
}
func (nc *NotificationConfig[T]) WithNoArchiveNotification() *NotificationConfig[T] {
nc.ArchiveNotification = sinkNotification[T]
return nc
}
// WithNoDeleteNotification disables the delete notification.
func (nc *NotificationConfig[T]) WithNoDeleteNotification() *NotificationConfig[T] {
nc.DeleteNotification = sinkNotification[T]
nc.NeedDeleteNotification = false
return nc
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,203 @@
package papitemplate
import (
"context"
api "github.com/tech/sendico/pkg/api/http"
notifications "github.com/tech/sendico/pkg/messaging/envelope"
"github.com/tech/sendico/pkg/mlogger"
model "github.com/tech/sendico/pkg/model/notification"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"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 primitive.ObjectID, t model.NotificationAction) notifications.Envelope) *ProtectedAPI[T] {
a.nconfig.WithNotifications(factory)
a.Logger.Info("Notificatons handler installed")
return a
}
// WithNoCreateNotification disables the create notification.
func (a *ProtectedAPI[T]) WithNoCreateNotification() *ProtectedAPI[T] {
a.nconfig.WithNoCreateNotification()
a.Logger.Info("Object creation notificaton disabled")
return a
}
// WithNoUpdateNotification disables the update notification.
func (a *ProtectedAPI[T]) WithNoUpdateNotification() *ProtectedAPI[T] {
a.nconfig.WithNoUpdateNotification()
a.Logger.Info("Object update notificaton disabled")
return a
}
// WithNoDeleteNotification disables the delete notification.
func (a *ProtectedAPI[T]) WithNoDeleteNotification() *ProtectedAPI[T] {
a.nconfig.WithNoDeleteNotification()
a.Logger.Info("Object deletion notificaton disabled")
return a
}
func (a *ProtectedAPI[T]) WithNoCreate() *ProtectedAPI[T] {
a.config.WithNoCreate()
a.Logger.Info("Create handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithCreateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithCreateHandler(handler)
a.Logger.Info("Create handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoList() *ProtectedAPI[T] {
a.config.WithNoList()
a.Logger.Info("List handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithListHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithListHandler(handler)
a.Logger.Info("List handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoGet() *ProtectedAPI[T] {
a.config.WithNoGet()
a.Logger.Info("Get handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithGetHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithGetHandler(handler)
a.Logger.Info("Get handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithReorderHandler(reorder ReorderConfig) *ProtectedAPI[T] {
a.config.WithReorderHandler(reorder)
a.Logger.Info("Reorder handler installed")
return a
}
func (a *ProtectedAPI[T]) WithTaggableHandler(taggable TaggableConfig) *ProtectedAPI[T] {
a.config.WithTaggableHandler(taggable)
a.Logger.Info("Taggable handlers installed")
return a
}
func (a *ProtectedAPI[T]) WithNoUpdate() *ProtectedAPI[T] {
a.config.WithNoUpdate()
a.Logger.Info("Update handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithUpdateHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithUpdateHandler(handler)
a.Logger.Info("Update handler overridden")
return a
}
func (a *ProtectedAPI[T]) WithNoDelete() *ProtectedAPI[T] {
a.config.WithNoDelete()
a.Logger.Info("Delete handler disabled")
return a
}
func (a *ProtectedAPI[T]) WithDeleteHandler(handler sresponse.AccountHandlerFunc) *ProtectedAPI[T] {
a.config.WithDeleteHandler(handler)
a.Logger.Info("Delete handler overriden")
return a
}
func CreateAPI[T any](a eapi.API, dbFactory func() (ProtectedDB[T], error), parent, resource mservice.Type) (*ProtectedAPI[T], error) {
p := &ProtectedAPI[T]{
Logger: a.Logger().Named(resource),
Oph: mutil.CreatePH("org"), // to avoid collision with organizaitons_ref when
Pph: mutil.CreatePH(parent),
resource: resource,
Cph: mutil.CreatePH(resource),
a: a,
config: NewConfig(),
nconfig: NewNotificationConfig[*T](a.Register().Messaging().Producer()),
}
var err error
if p.DB, err = dbFactory(); err != nil {
p.Logger.Error("Failed to create protected database", zap.Error(err))
return nil, err
}
return p, nil
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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/bson/primitive"
"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, primitive.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), primitive.NilObjectID)
}
// 2) perform the add/remove
if err := apply(ctx, &it); err != nil {
a.logger.Warn(fmt.Sprintf("failed to %s role policy", opName), zap.Error(err), zap.Any("policy", &it))
return err
}
}
return nil
}
// REMOVE
if req.Remove != nil {
if err := handle(req.Remove, model.ActionDelete, "remove", func(ctx context.Context, it *model.RolePolicy) error {
return a.auth.Permission().RevokeFromRole(ctx, it)
}); err != nil {
return nil, err
}
}
// ADD
if req.Add != nil {
if err := handle(req.Add, model.ActionCreate, "add", func(ctx context.Context, it *model.RolePolicy) error {
return a.auth.Permission().GrantToRole(ctx, it)
}); err != nil {
return nil, err
}
}
return nil, nil
}

View File

@@ -0,0 +1,85 @@
package permissionsimp
import (
"context"
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"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.ObjRef("account_ref", 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.ObjRef("account_ref", 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.ObjRef("account_ref", 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 primitive.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.ObjRef("account_ref", 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.ObjRef("account_ref", 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.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.ObjRef("role_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
return response.Success(a.logger)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
package permissionsimp
import (
"context"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"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[primitive.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, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Debug("Error occurred", zap.Error(err))
response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access to permissions denied")
response.AccessDenied(a.logger, a.Name(), "no required permissiosn to read account permissions data")
}
var org model.Organization
if err := a.db.Get(ctx, account.ID, orgRef, &org); err != nil {
a.logger.Warn("Failed to fetch venue", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err)
}
roles := make([]model.Role, 0)
permissions := make([]model.Permission, 0)
for _, employee := range org.Members {
rls, prms, err := a.enforcer.GetPermissions(ctx, employee, orgRef)
if len(rls) == 0 {
a.logger.Warn("No roles defined for account", mzap.ObjRef("employee_ref", employee), mzap.ObjRef("organization_ref", orgRef))
return response.NotFound(a.logger, a.Name(), "User has no roles assigned")
}
if err != nil {
a.logger.Warn("Failed to fetch account policies", zap.Error(err), mzap.ObjRef("organization_ref", orgRef))
return response.Auto(a.logger, a.Name(), err)
}
roles = append(roles, rls...)
permissions = append(permissions, prms...)
}
return a.permissions(ctx, orgRef, roles, permissions, accessToken)
}

View File

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

View File

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

View File

@@ -0,0 +1,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)
}