From 9b1d371cb189cdec545af1d8f00d1f7427cf279f Mon Sep 17 00:00:00 2001 From: optclblast Date: Sun, 12 May 2024 16:48:20 +0300 Subject: [PATCH] org creatin and listing with pagianation implemented with one small bug --- backend/internal/factory/interface.go | 11 +- backend/internal/factory/wire_gen.go | 3 +- .../interface/rest/controllers/auth.go | 8 +- .../rest/controllers/organization.go | 50 +++++++- backend/internal/interface/rest/domain/dto.go | 30 +++++ .../interface/rest/domain/organization.go | 9 ++ .../interface/rest/presenters/auth.go | 9 +- .../rest/presenters/organizations.go | 74 +++++++++++ backend/internal/interface/rest/server.go | 10 +- .../interactors/organizations/interactor.go | 115 ++++++++++++++++++ .../repository/organizations/repository.go | 109 ++++++++++++++++- backend/migrations/blockd.sql | 2 +- 12 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 backend/internal/interface/rest/domain/organization.go create mode 100644 backend/internal/interface/rest/presenters/organizations.go diff --git a/backend/internal/factory/interface.go b/backend/internal/factory/interface.go index 0039e31..723a33e 100644 --- a/backend/internal/factory/interface.go +++ b/backend/internal/factory/interface.go @@ -18,10 +18,11 @@ import ( var interfaceSet wire.ProviderSet = wire.NewSet( provideAuthController, - provideOrganizationsCOntroller, + provideOrganizationsController, provideControllers, provideAuthPresenter, + provideOrganizationsPresenter, ) func provideLogger(c config.Config) *slog.Logger { @@ -53,6 +54,10 @@ func provideAuthPresenter( return presenters.NewAuthPresenter(jwtInteractor) } +func provideOrganizationsPresenter() presenters.OrganizationsPresenter { + return presenters.NewOrganizationsPresenter() +} + func provideAuthController( log *slog.Logger, usersInteractor users.UsersInteractor, @@ -67,13 +72,15 @@ func provideAuthController( ) } -func provideOrganizationsCOntroller( +func provideOrganizationsController( log *slog.Logger, organizationsInteractor organizations.OrganizationsInteractor, + presenter presenters.OrganizationsPresenter, ) controllers.OrganizationsController { return controllers.NewOrganizationsController( log.WithGroup("organizations-controller"), organizationsInteractor, + presenter, ) } diff --git a/backend/internal/factory/wire_gen.go b/backend/internal/factory/wire_gen.go index e199e6a..8101b53 100644 --- a/backend/internal/factory/wire_gen.go +++ b/backend/internal/factory/wire_gen.go @@ -27,7 +27,8 @@ func ProvideService(c config.Config) (service.Service, func(), error) { authController := provideAuthController(logger, usersInteractor, authPresenter, jwtInteractor) organizationsRepository := provideOrganizationsRepository(db) organizationsInteractor := provideOrganizationsInteractor(logger, organizationsRepository) - organizationsController := provideOrganizationsCOntroller(logger, organizationsInteractor) + organizationsPresenter := provideOrganizationsPresenter() + organizationsController := provideOrganizationsController(logger, organizationsInteractor, organizationsPresenter) rootController := provideControllers(logger, authController, organizationsController) server := provideRestServer(logger, rootController, c, jwtInteractor) serviceService := service.NewService(logger, server) diff --git a/backend/internal/interface/rest/controllers/auth.go b/backend/internal/interface/rest/controllers/auth.go index 866f007..53a5067 100644 --- a/backend/internal/interface/rest/controllers/auth.go +++ b/backend/internal/interface/rest/controllers/auth.go @@ -61,7 +61,7 @@ func (c *authController) Join(w http.ResponseWriter, req *http.Request) ([]byte, return nil, fmt.Errorf("error invalid mnemonic. %w", ErrorAuthInvalidMnemonic) } - ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second) defer cancel() user, err := c.usersInteractor.Create(ctx, users.CreateParams{ @@ -78,7 +78,7 @@ func (c *authController) Join(w http.ResponseWriter, req *http.Request) ([]byte, c.log.Debug("join request", slog.String("user id", user.ID.String())) - return c.presenter.ResponseJoin(w, user) + return c.presenter.ResponseJoin(user) } // NIT: wrap with idempotent action handler @@ -90,7 +90,7 @@ func (c *authController) Login(w http.ResponseWriter, req *http.Request) ([]byte c.log.Debug("login request", slog.String("mnemonic", request.Mnemonic)) - ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second) + ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second) defer cancel() seed, err := hdwallet.NewSeedFromMnemonic(request.Mnemonic) @@ -111,7 +111,7 @@ func (c *authController) Login(w http.ResponseWriter, req *http.Request) ([]byte c.log.Debug("login request", slog.String("user id", users[0].ID.String())) - return c.presenter.ResponseLogin(w, users[0]) + return c.presenter.ResponseLogin(users[0]) } // const mnemonicEntropyBitSize int = 256 diff --git a/backend/internal/interface/rest/controllers/organization.go b/backend/internal/interface/rest/controllers/organization.go index b0994fa..44c0a51 100644 --- a/backend/internal/interface/rest/controllers/organization.go +++ b/backend/internal/interface/rest/controllers/organization.go @@ -1,43 +1,85 @@ package controllers import ( + "context" "fmt" "log/slog" "net/http" + "time" "github.com/emochka2007/block-accounting/internal/interface/rest/domain" "github.com/emochka2007/block-accounting/internal/interface/rest/presenters" + "github.com/emochka2007/block-accounting/internal/pkg/ctxmeta" "github.com/emochka2007/block-accounting/internal/usecase/interactors/organizations" ) type OrganizationsController interface { NewOrganization(w http.ResponseWriter, r *http.Request) ([]byte, error) + ListOrganizations(w http.ResponseWriter, r *http.Request) ([]byte, error) } type organizationsController struct { log *slog.Logger orgInteractor organizations.OrganizationsInteractor + presenter presenters.OrganizationsPresenter } func NewOrganizationsController( log *slog.Logger, orgInteractor organizations.OrganizationsInteractor, + presenter presenters.OrganizationsPresenter, ) OrganizationsController { return &organizationsController{ log: log, orgInteractor: orgInteractor, + presenter: presenter, } } func (c *organizationsController) NewOrganization(w http.ResponseWriter, r *http.Request) ([]byte, error) { - _, err := presenters.CreateRequest[domain.NewOrganizationRequest](r) + req, err := presenters.CreateRequest[domain.NewOrganizationRequest](r) if err != nil { return nil, fmt.Errorf("error build request. %w", err) } - // todo call int.Create + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() - // todo build response + org, err := c.orgInteractor.Create(ctx, organizations.CreateParams{ + Name: req.Name, + Address: req.Address, + WalletMnemonic: req.WalletMnemonic, + }) + if err != nil { + return nil, fmt.Errorf("error create new organization. %w", err) + } - return nil, nil + return c.presenter.ResponseCreate(org) +} + +func (c *organizationsController) ListOrganizations(w http.ResponseWriter, r *http.Request) ([]byte, error) { + req, err := presenters.CreateRequest[domain.ListOrganizationsRequest](r) + if err != nil { + return nil, fmt.Errorf("error build request. %w", err) + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + user, err := ctxmeta.User(ctx) + if err != nil { + return nil, fmt.Errorf("error fetch user from context. %w", err) + } + + resp, err := c.orgInteractor.List(ctx, organizations.ListParams{ + UserId: user.Id(), + Cursor: req.Cursor, + Limit: req.Limit, + OffsetDate: time.UnixMilli(req.OffsetDate), + }) + if err != nil { + return nil, fmt.Errorf("error fetch organizations list. %w", err) + } + + return c.presenter.ResponseList(resp.Organizations, resp.NextCursor) } diff --git a/backend/internal/interface/rest/domain/dto.go b/backend/internal/interface/rest/domain/dto.go index 8823591..e649eb7 100644 --- a/backend/internal/interface/rest/domain/dto.go +++ b/backend/internal/interface/rest/domain/dto.go @@ -5,6 +5,20 @@ import ( "fmt" ) +// Generic + +type Collection[T any] struct { + Items []T `json:"items,omitempty"` + Pagination Pagination `json:"pagination,omitempty"` +} + +type Pagination struct { + NextCursor string `json:"next_cursor,omitempty"` + TotalItems uint32 `json:"total_items,omitempty"` +} + +// Auth related DTO's + type JoinRequest struct { Name string `json:"name,omitempty"` Credentals struct { @@ -28,12 +42,28 @@ type LoginResponse struct { Token string `json:"token"` } +// Organizations + type NewOrganizationRequest struct { Name string `json:"name"` Address string `json:"address"` WalletMnemonic string `json:"wallet_mnemonic,omitempty"` } +type NewOrganizationResponse struct { + Organization Organization `json:"organization"` +} + +type ListOrganizationsRequest struct { + Cursor string `json:"cursor,omitempty"` + Limit uint8 `json:"limit,omitempty"` // Default: 50, Max: 50 + OffsetDate int64 `json:"offset_date,omitempty"` // List organizations, updated since the date +} + +type ListOrganizationsResponse struct { + Collection[Organization] +} + func BuildRequest[T any](data []byte) (*T, error) { var req T diff --git a/backend/internal/interface/rest/domain/organization.go b/backend/internal/interface/rest/domain/organization.go new file mode 100644 index 0000000..d61926a --- /dev/null +++ b/backend/internal/interface/rest/domain/organization.go @@ -0,0 +1,9 @@ +package domain + +type Organization struct { + Id string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + CreatedAt uint64 `json:"created_at"` + UpdatedAt uint64 `json:"updated_at"` +} diff --git a/backend/internal/interface/rest/presenters/auth.go b/backend/internal/interface/rest/presenters/auth.go index e4b5c5e..94a64d1 100644 --- a/backend/internal/interface/rest/presenters/auth.go +++ b/backend/internal/interface/rest/presenters/auth.go @@ -3,7 +3,6 @@ package presenters import ( "encoding/json" "fmt" - "net/http" "time" "github.com/emochka2007/block-accounting/internal/interface/rest/domain" @@ -12,8 +11,8 @@ import ( ) type AuthPresenter interface { - ResponseJoin(w http.ResponseWriter, user *models.User) ([]byte, error) - ResponseLogin(w http.ResponseWriter, user *models.User) ([]byte, error) + ResponseJoin(user *models.User) ([]byte, error) + ResponseLogin(user *models.User) ([]byte, error) } type authPresenter struct { @@ -28,7 +27,7 @@ func NewAuthPresenter( } } -func (p *authPresenter) ResponseJoin(w http.ResponseWriter, user *models.User) ([]byte, error) { +func (p *authPresenter) ResponseJoin(user *models.User) ([]byte, error) { resp := new(domain.JoinResponse) token, err := p.jwtInteractor.NewToken(user, 24*time.Hour) @@ -46,7 +45,7 @@ func (p *authPresenter) ResponseJoin(w http.ResponseWriter, user *models.User) ( return out, nil } -func (p *authPresenter) ResponseLogin(w http.ResponseWriter, user *models.User) ([]byte, error) { +func (p *authPresenter) ResponseLogin(user *models.User) ([]byte, error) { resp := new(domain.LoginResponse) token, err := p.jwtInteractor.NewToken(user, 24*time.Hour) diff --git a/backend/internal/interface/rest/presenters/organizations.go b/backend/internal/interface/rest/presenters/organizations.go new file mode 100644 index 0000000..facce23 --- /dev/null +++ b/backend/internal/interface/rest/presenters/organizations.go @@ -0,0 +1,74 @@ +package presenters + +import ( + "encoding/json" + "fmt" + + "github.com/emochka2007/block-accounting/internal/interface/rest/domain" + "github.com/emochka2007/block-accounting/internal/pkg/models" +) + +type OrganizationsPresenter interface { + ResponseCreate(organization *models.Organization) ([]byte, error) + ResponseList(orgs []*models.Organization, nextCursor string) ([]byte, error) + Organizations(orgs []*models.Organization) []domain.Organization +} + +type organizationsPresenter struct { +} + +func NewOrganizationsPresenter() OrganizationsPresenter { + return &organizationsPresenter{} +} + +func (p *organizationsPresenter) ResponseCreate(o *models.Organization) ([]byte, error) { + resp := &domain.NewOrganizationResponse{ + Organization: domain.Organization{ + Id: o.ID.String(), + Name: o.Name, + Address: o.Address, + CreatedAt: uint64(o.CreatedAt.UnixMilli()), + UpdatedAt: uint64(o.UpdatedAt.UnixMilli()), + }, + } + + out, err := json.Marshal(resp) + if err != nil { + return nil, fmt.Errorf("error marshal organization create response. %w", err) + } + + return out, nil +} + +func (p *organizationsPresenter) ResponseList(orgs []*models.Organization, nextCursor string) ([]byte, error) { + resp := &domain.ListOrganizationsResponse{ + Collection: domain.Collection[domain.Organization]{ + Items: p.Organizations(orgs), + Pagination: domain.Pagination{ + NextCursor: nextCursor, + TotalItems: uint32(len(orgs)), + }, + }, + } + + out, err := json.Marshal(resp) + if err != nil { + return nil, fmt.Errorf("error marshal organizations list response. %w", err) + } + + return out, nil +} + +func (p *organizationsPresenter) Organizations(orgs []*models.Organization) []domain.Organization { + out := make([]domain.Organization, len(orgs)) + + for i, o := range orgs { + out[i] = domain.Organization{ + Id: o.ID.String(), + Name: o.Name, + Address: o.Address, + } + } + + return out +} diff --git a/backend/internal/interface/rest/server.go b/backend/internal/interface/rest/server.go index 0a53c9b..9e1a290 100644 --- a/backend/internal/interface/rest/server.go +++ b/backend/internal/interface/rest/server.go @@ -100,7 +100,7 @@ func (s *Server) buildRouter() { router.Route("/organizations", func(r chi.Router) { r = r.With(s.withAuthorization) - // r.Get("/", s.handle(s.controllers.Auth.Invite, "list_organizations")) + r.Get("/", s.handle(s.controllers.Organizations.ListOrganizations, "list_organizations")) r.Post("/", s.handle(s.controllers.Organizations.NewOrganization, "new_organization")) r.Route("/{organization_id}", func(r chi.Router) { @@ -108,7 +108,7 @@ func (s *Server) buildRouter() { // r.Delete("/", s.handle(s.controllers.Organizations.NewOrganization, "delete_organization")) r.Route("/transactions", func(r chi.Router) { - r.Get("/", nil) // list + r.Get("/", nil) // list todo add cache r.Post("/", nil) // add r.Put("/{tx_id}", nil) // update / approve (or maybe body?) r.Delete("/{tx_id}", nil) // remove @@ -117,7 +117,7 @@ func (s *Server) buildRouter() { r.Post("/invite/{hash}", s.handle(s.controllers.Auth.Invite, "invite")) // create a new invite link r.Route("/employees", func(r chi.Router) { - r.Get("/", nil) // list + r.Get("/", nil) // list. todo add cache r.Post("/", nil) // add r.Put("/{employee_id}", nil) // update (or maybe body?) r.Delete("/{employee_id}", nil) // remove @@ -154,6 +154,8 @@ func (s *Server) handle( ) s.responseError(w, err) + + return } w.Header().Add("Content-Type", "application/json") @@ -186,6 +188,8 @@ func (s *Server) responseError(w http.ResponseWriter, e error) { } func (s *Server) handleMw(next http.Handler) http.Handler { + // todo add rate limiter && cirquit braker + fn := func(w http.ResponseWriter, r *http.Request) { s.closeMu.RLock() defer s.closeMu.RUnlock() diff --git a/backend/internal/usecase/interactors/organizations/interactor.go b/backend/internal/usecase/interactors/organizations/interactor.go index 5faeb68..3a70875 100644 --- a/backend/internal/usecase/interactors/organizations/interactor.go +++ b/backend/internal/usecase/interactors/organizations/interactor.go @@ -2,6 +2,9 @@ package organizations import ( "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" "log/slog" "time" @@ -13,17 +16,34 @@ import ( "github.com/google/uuid" ) +var ( + ErrorUnauthorizedAccess = errors.New("unauthorized access") +) + type CreateParams struct { Name string Address string WalletMnemonic string } +type ListParams struct { + Ids uuid.UUIDs + UserId uuid.UUID + + Cursor string + OffsetDate time.Time + Limit uint8 // Max limit is 50 (may change) +} + type OrganizationsInteractor interface { Create( ctx context.Context, params CreateParams, ) (*models.Organization, error) + List( + ctx context.Context, + params ListParams, + ) (*ListResponse, error) } type organizationsInteractor struct { @@ -41,6 +61,101 @@ func NewOrganizationsInteractor( } } +type organizationsListCursor struct { + Id uuid.UUID `json:"id"` +} + +func newOrganizationsListCursor(id ...uuid.UUID) *organizationsListCursor { + if len(id) > 0 { + return &organizationsListCursor{id[0]} + } + + return new(organizationsListCursor) +} + +func (c *organizationsListCursor) encode() (string, error) { + data, err := json.Marshal(c) + if err != nil { + return "", fmt.Errorf("couldn't marshal reaction id. %w", err) + } + return base64.StdEncoding.EncodeToString(data), nil + +} + +func (c *organizationsListCursor) decode(s string) error { + if c == nil { + return nil + } + + token, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return fmt.Errorf("error decode token. %w", err) + } + + return json.Unmarshal(token, c) +} + +type ListResponse struct { + Organizations []*models.Organization + NextCursor string +} + +func (i *organizationsInteractor) List( + ctx context.Context, + params ListParams, +) (*ListResponse, error) { + user, err := ctxmeta.User(ctx) + if err != nil { + return nil, fmt.Errorf("error fetch user from context. %w", err) + } + + if params.UserId != uuid.Nil { + if params.UserId != user.Id() { + return nil, fmt.Errorf("error unauthorized organizations list access. %w", ErrorUnauthorizedAccess) + } + } else { + params.UserId = user.Id() + } + + if params.Limit <= 0 || params.Limit > 50 { + params.Limit = 50 + } + + cursor := newOrganizationsListCursor() + + if params.Cursor != "" { + if err := cursor.decode(params.Cursor); err != nil { + return nil, fmt.Errorf("error decode cursor value. %w", err) // maybe just log error? + } + } + + orgs, err := i.orgRepository.Get(ctx, organizations.GetParams{ + UserId: params.UserId, + Ids: params.Ids, + OffsetDate: params.OffsetDate, + Limit: int64(params.Limit), + CursorId: cursor.Id, + }) + if err != nil { + return nil, fmt.Errorf("error fetch organizations. %w", err) + } + + var nextCursor string + + // BUG: pagination by cursor works with errors. empty set as return value + if len(orgs) >= 50 || len(orgs) >= int(params.Limit) { + cursor.Id = orgs[len(orgs)-1].ID + if nextCursor, err = cursor.encode(); err != nil { + return nil, fmt.Errorf("error encode next page token. %w", err) // maybe just log error? + } + } + + return &ListResponse{ + Organizations: orgs, + NextCursor: nextCursor, + }, nil +} + func (i *organizationsInteractor) Create( ctx context.Context, params CreateParams, diff --git a/backend/internal/usecase/repository/organizations/repository.go b/backend/internal/usecase/repository/organizations/repository.go index c42bfa2..62e5b21 100644 --- a/backend/internal/usecase/repository/organizations/repository.go +++ b/backend/internal/usecase/repository/organizations/repository.go @@ -3,6 +3,7 @@ package organizations import ( "context" "database/sql" + "errors" "fmt" "time" @@ -13,7 +14,12 @@ import ( ) type GetParams struct { - Ids uuid.UUIDs + Ids uuid.UUIDs + UserId uuid.UUID + + OffsetDate time.Time + CursorId uuid.UUID + Limit int64 } type AddParticipantParams struct { @@ -53,7 +59,12 @@ func (s *repositorySQL) Conn(ctx context.Context) sqltools.DBTX { func (r *repositorySQL) Create(ctx context.Context, org models.Organization) error { if err := sqltools.Transaction(ctx, r.db, func(ctx context.Context) (err error) { query := sq.Insert("organizations").Columns( - "id, name, address, wallet_seed, created_at, updated_at", + "id", + "name", + "address", + "wallet_seed", + "created_at", + "updated_at", ).Values( org.ID, org.Name, @@ -61,7 +72,7 @@ func (r *repositorySQL) Create(ctx context.Context, org models.Organization) err org.WalletSeed, org.CreatedAt, org.UpdatedAt, - ) + ).PlaceholderFormat(sq.Dollar) if _, err := query.RunWith(r.Conn(ctx)).ExecContext(ctx); err != nil { return fmt.Errorf("error insert new organization. %w", err) @@ -76,9 +87,95 @@ func (r *repositorySQL) Create(ctx context.Context, org models.Organization) err } func (r *repositorySQL) Get(ctx context.Context, params GetParams) ([]*models.Organization, error) { - panic("implement me!") + organizations := make([]*models.Organization, 0, params.Limit) - return nil, nil + if err := sqltools.Transaction(ctx, r.db, func(ctx context.Context) (err error) { + query := sq.Select( + "o.id", + "o.name", + "o.address", + "o.wallet_seed", + "o.created_at", + "o.updated_at", + ).From("organizations as o"). + Limit(uint64(params.Limit)). + PlaceholderFormat(sq.Dollar) + + if params.UserId != uuid.Nil { + query = query.InnerJoin("organizations_users as ou on o.id = ou.organization_id"). + Where(sq.Eq{ + "ou.user_id": params.UserId, + }) + } + + if params.CursorId != uuid.Nil { + query = query.Where(sq.Lt{ + "o.id": params.CursorId, + }) + } + + if params.Ids != nil { + query = query.Where(sq.Eq{ + "o.id": params.Ids, + }) + } + + if !params.OffsetDate.IsZero() { + query = query.Where(sq.GtOrEq{ + "o.updated_at": params.OffsetDate, + }) + } + + fmt.Println(query.ToSql()) + + rows, err := query.RunWith(r.Conn(ctx)).QueryContext(ctx) + if err != nil { + return fmt.Errorf("error fetch organizations from database. %w", err) + } + + defer func() { + if closeErr := rows.Close(); closeErr != nil { + err = errors.Join(fmt.Errorf("error close rows. %w", closeErr), err) + } + }() + + for rows.Next() { + var ( + id uuid.UUID + name string + address string + walletSeed []byte + createdAt time.Time + updatedAt time.Time + ) + + if err = rows.Scan( + &id, + &name, + &address, + &walletSeed, + &createdAt, + &updatedAt, + ); err != nil { + return fmt.Errorf("error scan row. %w", err) + } + + organizations = append(organizations, &models.Organization{ + ID: id, + Name: name, + Address: address, + WalletSeed: walletSeed, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }) + } + + return nil + }); err != nil { + return nil, fmt.Errorf("error execute transactional operation. %w", err) + } + + return organizations, nil } func (r *repositorySQL) Update(ctx context.Context, org models.Organization) error { @@ -109,7 +206,7 @@ func (r *repositorySQL) AddParticipant(ctx context.Context, params AddParticipan time.Now(), time.Now(), params.IsAdmin, - ) + ).PlaceholderFormat(sq.Dollar) if _, err := query.RunWith(r.Conn(ctx)).ExecContext(ctx); err != nil { return fmt.Errorf("error add new participant to organization. %w", err) diff --git a/backend/migrations/blockd.sql b/backend/migrations/blockd.sql index 704a12b..3d48ea4 100644 --- a/backend/migrations/blockd.sql +++ b/backend/migrations/blockd.sql @@ -54,7 +54,7 @@ create index if not exists index_user_id_organization_id create table organizations_users ( organization_id uuid not null references organizations(id), user_id uuid not null references users(id), - employee_id uuid references employees(id) default null, + employee_id uuid default null, added_at timestamp default current_timestamp, updated_at timestamp default current_timestamp, deleted_at timestamp default null,