diff --git a/backend/README.md b/backend/README.md index a1fce16..83c4d77 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,4 +224,49 @@ Response: "total_items": 2 } } -``` \ No newline at end of file +``` + +## POST **/{organization_id}/transactions** +### Request body: +description (string, optional) +amount (float) +to +max_fee_allowed +deadline + +### Example +Request: +``` bash +curl --location 'http://localhost:8081/organizations/018f8ccd-2431-7d21-a0c2-a2735c852764/transactions' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer TOKEN' \ +--data '{ + "description":"New test tx!", + "amount": 100, + "to":"0x323b5d4c32345ced77393b3530b1eed0f346429d", + "max_fee_allowed":5, + "deadline": 123456767 +}' +``` + +Response: +``` json +{ + "_type": "transaction", + "_links": { + "self": { + "href": "/organizations/{organization_id}/transactions" + } + }, + "id": "018f8ce2-dada-75fb-9745-8560e5736bec", + "description": "New test tx!", + "organization_id": "018f8ccd-2431-7d21-a0c2-a2735c852764", + "created_by": "018f8ccc-e4fc-7a46-9628-15f9c3301f5b", + "amount": 100, + "to": "MjtdTDI0XO13OTs1MLHu0PNGQp0=", + "max_fee_allowed": 5, + "deadline": 123456767, + "created_at": 1716055628507, + "updated_at": 1716055628507 +} +``` diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 9da947e..33078af 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -16,8 +16,8 @@ import ( func main() { app := &cli.App{ - Name: "biocom-ioannes", - Version: "0.0.1a", + Name: "blockd", + Version: "0.1.0", Commands: commands.Commands(), Flags: []cli.Flag{ // common diff --git a/backend/internal/factory/interface.go b/backend/internal/factory/interface.go index bf17694..1322a80 100644 --- a/backend/internal/factory/interface.go +++ b/backend/internal/factory/interface.go @@ -93,6 +93,7 @@ func provideTxController( return controllers.NewTransactionsController( log.WithGroup("transactions-controller"), txInteractor, + presenters.NewTransactionsPresenter(), ) } diff --git a/backend/internal/interface/rest/controllers/transactions.go b/backend/internal/interface/rest/controllers/transactions.go index 74ba2cd..66040a5 100644 --- a/backend/internal/interface/rest/controllers/transactions.go +++ b/backend/internal/interface/rest/controllers/transactions.go @@ -1,9 +1,15 @@ 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/transactions" ) @@ -15,20 +21,55 @@ type TransactionsController interface { type transactionsController struct { log *slog.Logger txInteractor transactions.TransactionsInteractor + txPresenter presenters.TransactionsPresenter } func NewTransactionsController( log *slog.Logger, txInteractor transactions.TransactionsInteractor, + txPresenter presenters.TransactionsPresenter, ) TransactionsController { return &transactionsController{ log: log, txInteractor: txInteractor, + txPresenter: txPresenter, } } func (c *transactionsController) New(w http.ResponseWriter, r *http.Request) ([]byte, error) { - panic("implement me!") + req, err := presenters.CreateRequest[domain.NewTransactionRequest](r) + if err != nil { + return nil, fmt.Errorf("error build new transaction request. %w", err) + } + + ctx, cancel := context.WithTimeout(r.Context(), 30000*time.Second) + defer cancel() + + organizationID, err := ctxmeta.OrganizationId(ctx) + if err != nil { + return nil, fmt.Errorf("error fetch organization ID from context. %w", err) + } + + requestTx, err := c.txPresenter.RequestTransaction(ctx, req) + if err != nil { + return nil, fmt.Errorf("error build transaction model from request. %w", err) + } + + c.log.Debug( + "new reuqest", + slog.Any("req", req), + slog.String("org id", organizationID.String()), + ) + + tx, err := c.txInteractor.Create(ctx, transactions.CreateParams{ + OrganizationId: organizationID, + Tx: requestTx, + }) + if err != nil { + return nil, fmt.Errorf("error create new transaction. %w", err) + } + + return c.txPresenter.ResponseNewTransaction(ctx, tx) } func (c *transactionsController) List(w http.ResponseWriter, r *http.Request) ([]byte, error) { diff --git a/backend/internal/interface/rest/domain/dto.go b/backend/internal/interface/rest/domain/dto.go index 444c613..84ddd5a 100644 --- a/backend/internal/interface/rest/domain/dto.go +++ b/backend/internal/interface/rest/domain/dto.go @@ -64,6 +64,31 @@ type ListOrganizationsRequest struct { OffsetDate int64 `json:"offset_date,omitempty"` // List organizations, updated since the date } +// Transactions + +type NewTransactionRequest struct { + Description string `json:"description,omitempty"` + Amount float64 `json:"amount,omitempty"` + ToAddr string `json:"to,omitempty"` + MaxFeeAllowed float64 `json:"max_fee_allowed,omitempty"` + Deadline int64 `json:"deadline,omitempty"` +} + +type ListTransactionsRequest struct { + IDs []string `json:"ids,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + To string `json:"to,omitempty"` + + WithCancelled bool `json:"cancelled,omitempty"` + WithConfirmed bool `json:"confirmed,omitempty"` + WithCommited bool `json:"commited,omitempty"` + WithExpired bool `json:"expired,omitempty"` + + 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 +} + func BuildRequest[T any](data []byte) (*T, error) { var req T diff --git a/backend/internal/interface/rest/domain/transaction.go b/backend/internal/interface/rest/domain/transaction.go new file mode 100644 index 0000000..7dba2a0 --- /dev/null +++ b/backend/internal/interface/rest/domain/transaction.go @@ -0,0 +1,17 @@ +package domain + +type Transaction struct { + Id string `json:"id"` + Description string `json:"description"` + OrganizationId string `json:"organization_id"` + CreatedBy string `json:"created_by"` + Amount float64 `json:"amount"` + ToAddr []byte `json:"to"` + MaxFeeAllowed float64 `json:"max_fee_allowed"` + Deadline int64 `json:"deadline,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + ConfirmedAt int64 `json:"confirmed_at,omitempty"` + CancelledAt int64 `json:"cancelled_at,omitempty"` + CommitedAt int64 `json:"commited_at,omitempty"` +} diff --git a/backend/internal/interface/rest/errors.go b/backend/internal/interface/rest/errors.go index adf8241..f61e867 100644 --- a/backend/internal/interface/rest/errors.go +++ b/backend/internal/interface/rest/errors.go @@ -8,6 +8,10 @@ import ( "github.com/emochka2007/block-accounting/internal/usecase/interactors/jwt" ) +var ( + ErrorBadPathParams = errors.New("bad path params") +) + type apiError struct { Code int `json:"code"` Message string `json:"message"` @@ -22,6 +26,9 @@ func buildApiError(code int, message string) apiError { func mapError(err error) apiError { switch { + // server error + case errors.Is(err, ErrorBadPathParams): + return buildApiError(http.StatusBadRequest, "Invalid Path Params") // auth controller errors case errors.Is(err, controllers.ErrorAuthInvalidMnemonic): return buildApiError(http.StatusBadRequest, "Invalid Mnemonic") diff --git a/backend/internal/interface/rest/presenters/tx.go b/backend/internal/interface/rest/presenters/tx.go new file mode 100644 index 0000000..381e3f3 --- /dev/null +++ b/backend/internal/interface/rest/presenters/tx.go @@ -0,0 +1,111 @@ +package presenters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/emochka2007/block-accounting/internal/interface/rest/domain" + "github.com/emochka2007/block-accounting/internal/interface/rest/domain/hal" + "github.com/emochka2007/block-accounting/internal/pkg/ctxmeta" + "github.com/emochka2007/block-accounting/internal/pkg/models" + "github.com/ethereum/go-ethereum/common" +) + +var ( + ErrorInvalidHexAddress = errors.New("error invalid hex address") +) + +type TransactionsPresenter interface { + RequestTransaction(ctx context.Context, r *domain.NewTransactionRequest) (models.Transaction, error) + ResponseTransaction(ctx context.Context, tx *models.Transaction) (*hal.Resource, error) + ResponseNewTransaction(ctx context.Context, tx *models.Transaction) ([]byte, error) +} + +type transactionsPresenter struct { +} + +func NewTransactionsPresenter() TransactionsPresenter { + return &transactionsPresenter{} +} + +// RequestTransaction returns a Transaction model WITHOUT CreatedBy user set. CreatedAt set as time.Now() +func (p *transactionsPresenter) RequestTransaction( + ctx context.Context, r *domain.NewTransactionRequest, +) (models.Transaction, error) { + if !common.IsHexAddress(r.ToAddr) { + return models.Transaction{}, ErrorInvalidHexAddress + } + + toAddress := common.HexToAddress(r.ToAddr) + + organizationID, err := ctxmeta.OrganizationId(ctx) + if err != nil { + return models.Transaction{}, fmt.Errorf("error fetch organization id from context. %w", err) + } + + return models.Transaction{ + OrganizationId: organizationID, + Description: r.Description, + Amount: r.Amount, + ToAddr: toAddress.Bytes(), + MaxFeeAllowed: r.MaxFeeAllowed, + Deadline: time.UnixMilli(r.Deadline), + CreatedAt: time.Now(), + }, nil +} + +func (c *transactionsPresenter) ResponseTransaction( + ctx context.Context, + tx *models.Transaction, +) (*hal.Resource, error) { + r := &domain.Transaction{ + Id: tx.Id.String(), + Description: tx.Description, + OrganizationId: tx.OrganizationId.String(), + CreatedBy: tx.CreatedBy.Id().String(), + Amount: tx.Amount, + ToAddr: tx.ToAddr, + MaxFeeAllowed: tx.MaxFeeAllowed, + Deadline: tx.Deadline.UnixMilli(), + CreatedAt: tx.CreatedAt.UnixMilli(), + UpdatedAt: tx.UpdatedAt.UnixMilli(), + } + + if !tx.ConfirmedAt.IsZero() { + r.ConfirmedAt = tx.ConfirmedAt.UnixMilli() + } + + if !tx.CancelledAt.IsZero() { + r.CancelledAt = tx.CancelledAt.UnixMilli() + } + + if !tx.CommitedAt.IsZero() { + r.CommitedAt = tx.CommitedAt.UnixMilli() + } + + return hal.NewResource( + r, + "/organizations/{organization_id}/transactions", + hal.WithType("transaction"), + ), nil +} + +func (c *transactionsPresenter) ResponseNewTransaction( + ctx context.Context, + tx *models.Transaction, +) ([]byte, error) { + dtoTx, err := c.ResponseTransaction(ctx, tx) + if err != nil { + return nil, fmt.Errorf("error map tx to dto. %w", err) + } + + out, err := json.Marshal(dtoTx) + if err != nil { + return nil, fmt.Errorf("error marshal tx to hal resource. %w", err) + } + + return out, nil +} diff --git a/backend/internal/interface/rest/server.go b/backend/internal/interface/rest/server.go index c71f723..e755aca 100644 --- a/backend/internal/interface/rest/server.go +++ b/backend/internal/interface/rest/server.go @@ -19,6 +19,7 @@ import ( "github.com/go-chi/chi/v5" mw "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/render" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" ) @@ -237,9 +238,27 @@ func (s *Server) withAuthorization(next http.Handler) http.Handler { return } - next.ServeHTTP(w, r.WithContext( - ctxmeta.UserContext(r.Context(), user), - )) + ctx := ctxmeta.UserContext(r.Context(), user) + + if organizationID := chi.URLParam(r, "organization_id"); organizationID != "" { + organizationUUID, err := uuid.Parse(organizationID) + if err != nil { + s.log.Warn( + "invalid path org id", + slog.String("remote_addr", r.RemoteAddr), + slog.String("endpoint", r.RequestURI), + slog.String("org path id", organizationID), + logger.Err(err), + ) + + s.responseError(w, ErrorBadPathParams) + return + } + + ctx = ctxmeta.OrganizationIdContext(ctx, organizationUUID) + } + + next.ServeHTTP(w, r.WithContext(ctx)) } return http.HandlerFunc(fn) diff --git a/backend/internal/pkg/models/tx.go b/backend/internal/pkg/models/tx.go index 7b9cbab..9a79ef8 100644 --- a/backend/internal/pkg/models/tx.go +++ b/backend/internal/pkg/models/tx.go @@ -12,11 +12,11 @@ type Transaction struct { Description string OrganizationId uuid.UUID CreatedBy *OrganizationUser - Amount int64 + Amount float64 ToAddr []byte - MaxFeeAllowed int64 + MaxFeeAllowed float64 Deadline time.Time CreatedAt time.Time diff --git a/backend/internal/usecase/interactors/transactions/interactor.go b/backend/internal/usecase/interactors/transactions/interactor.go index 009e0d1..f28f5ee 100644 --- a/backend/internal/usecase/interactors/transactions/interactor.go +++ b/backend/internal/usecase/interactors/transactions/interactor.go @@ -113,10 +113,15 @@ func (i *transactionsInteractor) Create( tx := params.Tx + if tx.Id == uuid.Nil { + tx.Id = uuid.Must(uuid.NewV7()) + } + participant, err := i.orgInteractor.Participant(ctx, organizations.ParticipantParams{ - ID: user.Id(), - ActiveOnly: true, - UsersOnly: true, + ID: user.Id(), + OrganizationID: params.OrganizationId, + ActiveOnly: true, + UsersOnly: true, }) if err != nil { return nil, fmt.Errorf("error fetch actor prticipant. %w", err) @@ -124,6 +129,7 @@ func (i *transactionsInteractor) Create( tx.CreatedBy = participant.GetUser() tx.CreatedAt = time.Now() + tx.UpdatedAt = tx.CreatedAt if err = i.txRepo.CreateTransaction(ctx, tx); err != nil { return nil, fmt.Errorf("error create new tx. %w", err) @@ -135,6 +141,7 @@ func (i *transactionsInteractor) Create( func (i *transactionsInteractor) Confirm(ctx context.Context, params ConfirmParams) (*models.Transaction, error) { panic("implement me!") } + func (i *transactionsInteractor) Cancel(ctx context.Context, params CancelParams) (*models.Transaction, error) { panic("implement me!") } diff --git a/backend/internal/usecase/repository/organizations/repository.go b/backend/internal/usecase/repository/organizations/repository.go index 7dee84c..a0b6f0b 100644 --- a/backend/internal/usecase/repository/organizations/repository.go +++ b/backend/internal/usecase/repository/organizations/repository.go @@ -361,45 +361,49 @@ func (r *repositorySQL) Participants( eg, egCtx := errgroup.WithContext(ctx) var employees []*models.Employee = make([]*models.Employee, 0, len(orgUsersModels)) - eg.Go(func() error { - ids := make(uuid.UUIDs, 0, len(orgUsersModels)) + if !params.UsersOnly { + eg.Go(func() error { + ids := make(uuid.UUIDs, 0, len(orgUsersModels)) - for _, m := range orgUsersModels { - if m.employeeID != uuid.Nil { - ids = append(ids, m.employeeID) + for _, m := range orgUsersModels { + if m.employeeID != uuid.Nil { + ids = append(ids, m.employeeID) + } } - } - employees, err = r.fetchEmployees(egCtx, fetchEmployeesParams{ - IDs: ids, - OrganizationId: params.OrganizationId, + employees, err = r.fetchEmployees(egCtx, fetchEmployeesParams{ + IDs: ids, + OrganizationId: params.OrganizationId, + }) + if err != nil { + return fmt.Errorf("error fetch employees. %w", err) + } + + return nil }) - if err != nil { - return fmt.Errorf("error fetch employees. %w", err) - } - - return nil - }) + } var usrs []*models.User - eg.Go(func() error { - ids := make(uuid.UUIDs, 0, len(orgUsersModels)) + if !params.EmployeesOnly { + eg.Go(func() error { + ids := make(uuid.UUIDs, 0, len(orgUsersModels)) - for _, m := range orgUsersModels { - if m.userID != uuid.Nil { - ids = append(ids, m.employeeID) + for _, m := range orgUsersModels { + if m.userID != uuid.Nil { + ids = append(ids, m.userID) + } } - } - usrs, err = r.usersRepository.Get(egCtx, users.GetParams{ - Ids: ids, + usrs, err = r.usersRepository.Get(egCtx, users.GetParams{ + Ids: ids, + }) + if err != nil { + return fmt.Errorf("error fetch users by ids. %w", err) + } + + return nil }) - if err != nil { - return fmt.Errorf("error fetch users by ids. %w", err) - } - - return nil - }) + } if err = eg.Wait(); err != nil { return fmt.Errorf("error organizations users entitiels. %w", err) @@ -422,20 +426,18 @@ func (r *repositorySQL) Participants( participants = append(participants, employee) } - var orgUser *models.OrganizationUser - for _, u := range usrs { if u.Id() == ou.userID { - orgUser = &models.OrganizationUser{ + participants = append(participants, &models.OrganizationUser{ User: *u, OrgPosition: ou.position, Admin: ou.isAdmin, Employee: employee, - } + }) + + break } } - - participants = append(participants, orgUser) } return nil @@ -479,7 +481,8 @@ func (r *repositorySQL) fetchOrganizationUsers( "ou.is_admin", ).Where(sq.Eq{ "ou.organization_id": params.OrganizationId, - }).PlaceholderFormat(sq.Dollar) + }).From("organizations_users as ou"). + PlaceholderFormat(sq.Dollar) if len(params.Ids) > 0 { ouQuery = ouQuery.Where(sq.Eq{ @@ -487,6 +490,8 @@ func (r *repositorySQL) fetchOrganizationUsers( }) } + fmt.Println(ouQuery.ToSql()) + rows, err := ouQuery.RunWith(r.Conn(ctx)).QueryContext(ctx) if err != nil { return fmt.Errorf("error fetch organization participants. %w", err) @@ -503,7 +508,7 @@ func (r *repositorySQL) fetchOrganizationUsers( organizationID uuid.UUID userID uuid.UUID employeeID uuid.UUID - position string + position sql.NullString addedAt time.Time updatedAt time.Time deletedAt sql.NullTime @@ -539,7 +544,7 @@ func (r *repositorySQL) fetchOrganizationUsers( organizationID: organizationID, userID: userID, employeeID: employeeID, - position: position, + position: position.String, addedAt: addedAt, updatedAt: updatedAt, deletedAt: deletedAt.Time, @@ -576,7 +581,8 @@ func (r *repositorySQL) fetchEmployees( "e.updated_at", ).Where(sq.Eq{ "e.organization_id": params.OrganizationId, - }).PlaceholderFormat(sq.Dollar) + }).From("employees as e"). + PlaceholderFormat(sq.Dollar) if len(params.IDs) > 0 { query = query.Where(sq.Eq{ @@ -584,6 +590,8 @@ func (r *repositorySQL) fetchEmployees( }) } + fmt.Println(query.ToSql()) + rows, err := query.RunWith(r.Conn(ctx)).QueryContext(ctx) if err != nil { return fmt.Errorf("error fetch employees from database. %w", err) diff --git a/backend/internal/usecase/repository/transactions/repository.go b/backend/internal/usecase/repository/transactions/repository.go index 37b5a4f..c3d0fe6 100644 --- a/backend/internal/usecase/repository/transactions/repository.go +++ b/backend/internal/usecase/repository/transactions/repository.go @@ -96,9 +96,9 @@ func (r *repositorySQL) GetTransactions( id uuid.UUID description string organizationId uuid.UUID - amount int64 + amount float64 toAddr []byte - maxFeeAllowed int64 + maxFeeAllowed float64 deadline sql.NullTime createdAt time.Time updatedAt time.Time @@ -188,21 +188,16 @@ func (r *repositorySQL) GetTransactions( func (r *repositorySQL) CreateTransaction(ctx context.Context, tx models.Transaction) error { if err := sqltools.Transaction(ctx, r.db, func(ctx context.Context) error { query := sq.Insert("transactions").Columns( - "t.id", - "t.description", - "t.organization_id", - "t.created_by", - "t.amount", - "t.to_addr", - "t.max_fee_allowed", - - // todo insert later - // "t.deadline", - // "t.created_at", - // "t.updated_at", - // "t.confirmed_at", - // "t.cancelled_at", - // "t.commited_at", + "id", + "description", + "organization_id", + "created_by", + "amount", + "to_addr", + "max_fee_allowed", + "deadline", + "created_at", + "updated_at", ).Values( tx.Id, tx.Description, @@ -211,7 +206,10 @@ func (r *repositorySQL) CreateTransaction(ctx context.Context, tx models.Transac tx.Amount, tx.ToAddr, tx.MaxFeeAllowed, - ) + tx.Deadline, + tx.CreatedAt, + tx.CreatedAt, + ).PlaceholderFormat(sq.Dollar) // todo add optional insertions diff --git a/backend/internal/usecase/repository/users/repository.go b/backend/internal/usecase/repository/users/repository.go index dca7acd..13967ee 100644 --- a/backend/internal/usecase/repository/users/repository.go +++ b/backend/internal/usecase/repository/users/repository.go @@ -72,6 +72,8 @@ func (r *repositorySQL) Get(ctx context.Context, params GetParams) ([]*models.Us query = query.Where("u.seed = ?", params.Seed) } + fmt.Println(query.ToSql()) + rows, err := query.RunWith(r.Conn(ctx)).QueryContext(ctx) if err != nil { return fmt.Errorf("error fetch data from database. %w", err) diff --git a/backend/migrations/blockd.sql b/backend/migrations/blockd.sql index 43a92e7..b0fe3f7 100644 --- a/backend/migrations/blockd.sql +++ b/backend/migrations/blockd.sql @@ -31,7 +31,7 @@ create table if not exists access_tokens ( refresh_token varchar(350) not null, refresh_token_expired_at timestamp, created_at timestamp default current_timestamp, - remote_addr string + remote_addr varchar(100) ); create index if not exists index_access_tokens_token_refresh_token @@ -94,12 +94,12 @@ create table if not exists transactions ( description text default 'New Transaction', organization_id uuid not null, created_by uuid not null, - amount bigint default 0, + amount decimal default 0, to_addr bytea not null, tx_index bytea default null, - max_fee_allowed bigint default 0, + max_fee_allowed decimal default 0, deadline timestamp default null, created_at timestamp default current_timestamp, @@ -144,7 +144,7 @@ create table contracts ( created_by uuid not null references users(id), organization_id uuid not null references organizations(id), - status tinyint default 0, + status smallint default 0, tx_index bytea default null, created_at timestamp default current_timestamp,