Merge pull request 'campaign' (#284) from campaign into develop

Reviewed-on: ebhomengo/niki#284
This commit is contained in:
hossein 2026-04-22 15:48:06 +00:00
commit 373ef9512f
18 changed files with 313 additions and 226 deletions

View File

@ -1,8 +1,9 @@
package domain package entity
import "time" import (
"git.gocasts.ir/ebhomengo/niki/types"
type ID uint64 "time"
)
type CampaignStatus string type CampaignStatus string
@ -15,7 +16,7 @@ const (
) )
type Campaign struct { type Campaign struct {
ID ID `json:"id"` ID types.ID `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
GoalAmount float64 `json:"goal_amount"` GoalAmount float64 `json:"goal_amount"`
@ -23,10 +24,10 @@ type Campaign struct {
Status CampaignStatus `json:"status"` Status CampaignStatus `json:"status"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"` DeadlineAt *time.Time `json:"deadline_at,omitempty"`
AdminID ID `json:"creator_id"` AdminID types.ID `json:"creator_id"`
} }
// Behavior // Behavior
func (c *Campaign) Activate() { func (c *Campaign) Activate() {
if c.Status == CampaignDraft { if c.Status == CampaignDraft {
c.Status = CampaignActive c.Status = CampaignActive

View File

@ -0,0 +1,57 @@
package mysql
import (
"context"
"git.gocasts.ir/ebhomengo/niki/domain/campaign/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
"git.gocasts.ir/ebhomengo/niki/types"
)
type DB struct {
conn *mysql.DB
}
func New(db *mysql.DB) *DB {
return &DB{conn: db}
}
// CreateCampaign creates a new campaign
func (d *DB) CreateAndSave(ctx context.Context, campaign entity.Campaign) (types.ID, error) {
const Op = "repository.mysql.campaign.create"
tx, err := d.conn.Conn().BeginTx(ctx, nil)
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
defer tx.Rollback()
query := `INSERT INTO campaigns (title, description, goal_amount, raised_amount,
status, deadline_at ,admin_id , created_at )
VALUES (?, ?, ?, ?, ?, ?, ? , NOW() )`
result, err := tx.ExecContext(ctx, query,
campaign.Title,
campaign.Description,
campaign.GoalAmount,
campaign.RaisedAmount,
campaign.Status,
campaign.DeadlineAt,
campaign.AdminID,
campaign.CreatedAt,
)
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
campaignID, err := result.LastInsertId()
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
if err := tx.Commit(); err != nil {
return 0, richerror.New(Op).WithErr(err)
}
return types.ID(campaignID), nil
}

View File

@ -1,29 +0,0 @@
package mysql
import (
"context"
"git.gocasts.ir/ebhomengo/niki/campaign/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/repository/mysql"
"git.gocasts.ir/ebhomengo/niki/types"
)
type CampaignRepository interface {
CreateAndSave(ctx context.Context, campaign *Campaign) error
FindByID(ctx context.Context, id ID) (*Campaign, error)
List(ctx context.Context, status CampaignStatus, limit, offset int) ([]*Campaign, error)
Delete(ctx context.Context, id ID) error
Update(ctx context.Context, campaign *Campaign) error
}

View File

@ -1,7 +0,0 @@
package domain
import (
"context"
"time"
)

View File

@ -1,48 +1,50 @@
package campaign package service
import ( import (
"context" "context"
"errors" // For standard errors "fmt"
"git.gocasts.ir/ebhomengo/niki/domain/campaign/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/types"
"time" "time"
"your_project/domain"
"your_project/pkg/richerror"
"your_project/repository"
) )
type ID = unit64 // CreateCampaign handles creation of a new campaign.
type EntityCampaign = domain.Campaign func (s *CampaignService) CreateCampaign(ctx context.Context, req entity.Campaign) (types.ID, error) {
const op = "service.campaign.create_campaign"
if err := validateCreateCampaignRequest(req); err != nil {
type CampaignServiceImp interface { return 0, richerror.New(op).WithErr(err)
CreateCampaign(ctx context.Context, req CampaignRepository) (types.ID, error)
}
type CampaignService struct {
repo repository.CampaignRepository
}
func NewCampaignService(
campaignRepo repository.CampaignRepository,
) *CampaignService {
return &CampaignService{
repo: campaignRepo,
} }
campaign := entity.Campaign{
Title: req.Title,
Description: req.Description,
GoalAmount: req.GoalAmount,
RaisedAmount: 0,
Status: req.Status,
DeadlineAt: req.DeadlineAt,
AdminID: req.AdminID,
CreatedAt: time.Now(),
}
id, err := s.repo.Create(ctx, campaign)
if err != nil {
return 0, richerror.New(op).WithErr(err)
}
return id, nil
} }
func validateCreateCampaignRequest(req entity.Campaign) error {
func (s *CampaignService) CreateCampaign(ctx context.Context, req CreateCampaignRequest) (ID, error) {
const Op = "service.campaign.create_campaign"
if req.Title == "" { if req.Title == "" {
return 0, richerror.New(Op).WithMessage("title is required") return errRequired("title")
} }
if req.GoalAmount <= 0 { if req.GoalAmount <= 0 {
return 0, richerror.New(Op).WithMessage("goal_amount must be greater than 0") return errInvalid("goal_amount must be greater than 0")
} }
if req.AdminID == 0 { if req.AdminID == 0 {
return 0, richerror.New(Op).WithMessage("admin_id is required") return errRequired("admin_id")
} }
validStatuses := map[string]bool{ validStatuses := map[string]bool{
@ -50,32 +52,20 @@ func (s *CampaignService) CreateCampaign(ctx context.Context, req CreateCampaign
"active": true, "active": true,
"completed": true, "completed": true,
"cancelled": true, "cancelled": true,
"paused": true "paused": true,
} }
if !validStatuses[req.Status] { if !validStatuses[string(req.Status)] {
return 0, richerror.New(Op).WithMessage("invalid status provided") return errInvalid("invalid status provided")
} }
return nil
newCampaign := &EntityCampaign{
Title: req.Title,
Description: req.Description,
GoalAmount: req.GoalAmount,
RaisedAmount: 0, // Initially 0
Status: EntityCampaign.Status(req.Status),
DeadlineAt: req.DeadlineAt,
AdminID: req.AdminID,
CreatedAt: time.Now(),
}
createdCampaignID, err := s.repo.CreateAndSave(ctx, newCampaign)
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
return createdCampaignID, nil
} }
// --- Helpers ---
func errRequired(field string) error {
return fmt.Errorf("%s is required", field)
}
func errInvalid(msg string) error {
return fmt.Errorf(msg)
}

View File

@ -0,0 +1,27 @@
package service
import (
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type GetCampaignResponse struct {
ID types.ID `json:"user_id"`
}
type AddCampaignRequest struct {
ID uint64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
GoalAmount float64 `json:"goal_amount"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
AdminID types.ID `json:"admin_id"`
}
type UpdateCampaignRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
GoalAmount *float64 `json:"goal_amount,omitempty"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
Status *string `json:"status,omitempty"` // draft/active/completed/paused/cancelled
}

View File

@ -0,0 +1,35 @@
package service
import (
"context"
_ "fmt"
"git.gocasts.ir/ebhomengo/niki/domain/campaign/entity"
"git.gocasts.ir/ebhomengo/niki/types"
)
type CampaignFilterParam struct {
AdminID types.ID
Status string
//nil true false
IsArchived *bool
}
type CampaignStorage interface {
Create(ctx context.Context, c entity.Campaign) (types.ID, error) // باید ID برگرداند
Update(ctx context.Context, c entity.Campaign) error
FindByID(ctx context.Context, id types.ID) (entity.Campaign, error)
FindAll(ctx context.Context, filter CampaignFilterParam) ([]entity.Campaign, error)
Archive(ctx context.Context, id types.ID) error // instead Delete
TotalDonations(ctx context.Context, campaignID types.ID) (int64, error)
}
type CampaignService struct {
repo CampaignStorage
}
// NewCampaignService constructs a new CampaignService.
func NewCampaignService(storage CampaignStorage) *CampaignService {
return &CampaignService{
repo: storage,
}
}

View File

@ -1,5 +0,0 @@
package donate_server
type Handler struct{}

View File

@ -1,7 +0,0 @@
package donate_server
import "github.com/labstack/echo/v4"
func (h Handler) RegisterRoutes(e *echo.Echo) {
}

View File

@ -1,16 +0,0 @@
package donate_server
import (
httpserver "git.gocasts.ir/ebhomengo/niki/delivery/http_server"
"github.com/labstack/echo/v4"
)
type Server struct {
Server httpserver.Server
Handler Handler
Router *echo.Echo
}
func (s Server) Start() {
s.Handler.RegisterRoutes(s.Router)
}

View File

@ -0,0 +1,43 @@
package http
import (
"git.gocasts.ir/ebhomengo/niki/domain/campaign/entity"
"git.gocasts.ir/ebhomengo/niki/domain/campaign/service"
httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg"
"github.com/labstack/echo/v4"
"net/http"
"time"
)
type Handler struct {
svc service.CampaignService
}
func NewHandler(svc service.CampaignService) Handler {
return Handler{svc: svc}
}
func (h Handler) createCampaign(c echo.Context) error {
var req entity.Campaign
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request body",
})
}
req.CreatedAt = time.Now()
req.RaisedAmount = 0
createdID, err := h.svc.CreateCampaign(c.Request().Context(), req)
if err != nil {
msg, code := httpmsg.Error(err)
c.Logger().Errorf("Service error creating campaign: %v (Code: %d)", err, code)
return c.JSON(code, msg)
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"message": "campaign created successfully",
"id": createdID,
})
}

View File

@ -0,0 +1,12 @@
package http
import (
"github.com/labstack/echo/v4"
"net/http"
)
func (s Server) healthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, echo.Map{
"message": "everything is good!",
})
}

View File

@ -0,0 +1,39 @@
package http
import (
"context"
"git.gocasts.ir/ebhomengo/niki/pkg/httpserver"
)
type Server struct {
handler Handler
HTTPServer *httpserver.Server
}
func NewServer(handler Handler, hS *httpserver.Server) Server {
return Server{handler: handler, HTTPServer: hS}
}
func (s Server) Serve() error {
s.registerRoutes()
if err := s.HTTPServer.Start(); err != nil {
return err
}
return nil
}
func (s Server) Stop(ctx context.Context) error {
return s.HTTPServer.Stop(ctx)
}
func (s Server) registerRoutes() {
router := s.HTTPServer.GetRouter()
router.GET("campaign/health-check", s.healthCheck)
r := router.Group("campaign")
r.POST("/", s.handler.createCampaign)
}

View File

@ -2,7 +2,7 @@ package mysql
import ( import (
"context" "context"
"git.gocasts.ir/ebhomengo/niki/campaign/entity" "git.gocasts.ir/ebhomengo/niki/donate_app/service/entity"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/repository/mysql" "git.gocasts.ir/ebhomengo/niki/repository/mysql"
"git.gocasts.ir/ebhomengo/niki/types" "git.gocasts.ir/ebhomengo/niki/types"
@ -16,11 +16,9 @@ func New(db *mysql.DB) *DB {
return &DB{conn: db} return &DB{conn: db}
} }
// Create adds a new participant to a campaign
func (d *DB) CreateCampaignParticipants(ctx context.Context, participant entity.CampaignParticipant) (types.ID, error) {
// CreateCampaign creates a new campaign const Op = "repository.mysql.campaign_participant.create"
func (d *DB) CreateAndSave(ctx context.Context, campaign entity.Campaign) (types.ID, error) {
const Op = "repository.mysql.campaign.create"
tx, err := d.conn.Conn().BeginTx(ctx, nil) tx, err := d.conn.Conn().BeginTx(ctx, nil)
if err != nil { if err != nil {
@ -28,67 +26,29 @@ func (d *DB) CreateAndSave(ctx context.Context, campaign entity.Campaign) (types
} }
defer tx.Rollback() defer tx.Rollback()
query := `INSERT INTO campaigns (title, description, goal_amount, raised_amount, query := `INSERT INTO campaign_participants (id,campaign_id, user_id, amount , created_at)
status, deadline_at ,admin_id , created_at ) VALUES (?, ?, ? , ? , NOW())`
VALUES (?, ?, ?, ?, ?, ?, ? , NOW() )`
result, err := tx.ExecContext(ctx, query, result, err := tx.ExecContext(ctx, query,
campaign.Title, participant.ID,
campaign.Description, participant.CampaignID,
campaign.GoalAmount, participant.UserID,
campaign.RaisedAmount, participant.Amount,
campaign.Status, participant.CreatedAt,
campaign.DeadlineAt,
campaign.AdminID,
campaign.CreatedAt,
) )
if err != nil { if err != nil {
return 0, richerror.New(Op).WithErr(err) return 0, richerror.New(Op).WithErr(err)
} }
campaignID, err := result.LastInsertId() id, err := result.LastInsertId()
if err != nil { if err != nil {
return 0, richerror.New(Op).WithErr(err) return 0, richerror.New(Op).WithErr(err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return 0, richerror.New(Op).WithErr(err) return 0, richerror.New(Op).WithErr(err)
} }
return types.ID(campaignID), nil return types.ID(id), nil
} }
// Create adds a new participant to a campaign
func (d *DB) CreateCampaignParticipants(ctx context.Context, participant entity.CampaignParticipant) (types.ID, error) {
const Op = "repository.mysql.campaign_participant.create"
query := `INSERT INTO campaign_participants (id,campaign_id, user_id, amount , created_at)
VALUES (?, ?, ? , ? , NOW())`
result, err := d.conn.ExecContext(ctx, query,
participant.ID,
participant.CampaignID,
participant.UserID,
participant.Amount,
participant.CreatedAt
)
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, richerror.New(Op).WithErr(err)
}
return types.ID(id), nil
}

View File

@ -1,14 +1,13 @@
package service package entity
import "time"
type ID uint64 type ID uint64
type CampaignParticipant struct { type CampaignParticipant struct {
ID ID `json:"id"` ID ID `json:"id"`
CampaignID ID `json:"campaign_id"` CampaignID ID `json:"campaign_id"`
UserID ID `json:"user_id"` UserID ID `json:"user_id"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
}
}

View File

@ -1 +1,26 @@
package service package service
import (
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type GetCampaignResponse struct {
ID types.ID `json:"user_id"`
}
type AddCampaignRequest struct {
Title string `json:"title"`
Description string `json:"description"`
GoalAmount float64 `json:"goal_amount"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
AdminID types.ID `json:"admin_id"`
}
type UpdateCampaignRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
GoalAmount *float64 `json:"goal_amount,omitempty"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
Status *string `json:"status,omitempty"` // draft/active/completed/paused/cancelled
}

View File

@ -1,38 +1 @@
package service package service
import (
"context"
"errors"
"time"
"git.gocasts.ir/ebhomengo/niki/campaign/entity"
"git.gocasts.ir/ebhomengo/niki/repository"
richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error"
"git.gocasts.ir/ebhomengo/niki/types"
)
type CampaignService struct {
repo repository.repo
}
// type CampaignServiceInterface interface {
// CreateCampaign(ctx context.Context, req CampaignRepository) (types.ID, error)
// }
func NewCampaignService(
repo repository.CampaignRepository,
participantRepo repository.CampaignParticipantRepository,
) *CampaignService {
return &CampaignService{
repo: repo,
participantRepo: participantRepo,
}
}