Merge pull request 'campaign-domain' (#305) from campaign-domain into develop

Reviewed-on: ebhomengo/niki#305
This commit is contained in:
hossein 2026-05-06 04:54:31 +00:00
commit 9f4e011357
20 changed files with 213 additions and 167 deletions

View File

@ -5,39 +5,31 @@ import (
"time" "time"
) )
type CampaignStatus string
const (
CampaignDraft CampaignStatus = "draft"
CampaignActive CampaignStatus = "active"
CampaignFinished CampaignStatus = "completed"
CampaignPaused CampaignStatus = "paused"
CampaignCanceled CampaignStatus = "cancelled"
)
type Campaign struct { type Campaign struct {
ID types.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"` Link string `json:"link"`
RaisedAmount float64 `json:"raised_amount"` Slogan string `json:"slogan"` //
Status CampaignStatus `json:"status"` GoalAmount float64 `json:"goal_amount"`
CreatedAt time.Time `json:"created_at"` RaisedAmount float64 `json:"raised_amount"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"` Status types.CampaignStatus `json:"status"`
AdminID types.ID `json:"creator_id"` CreatedAt time.Time `json:"created_at"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
AdminID types.ID `json:"creator_id"`
} }
// Behavior // Behavior
func (c *Campaign) Activate() { func (c *Campaign) Activate() {
if c.Status == CampaignDraft { if c.Status == types.CampaignDraft {
c.Status = CampaignActive c.Status = types.CampaignActive
} }
} }
func (c *Campaign) AddFunds(amount float64) { func (c *Campaign) AddFunds(amount float64) {
c.RaisedAmount += amount c.RaisedAmount += amount
if c.RaisedAmount >= c.GoalAmount { if c.RaisedAmount >= c.GoalAmount {
c.Status = CampaignFinished c.Status = types.CampaignFinished
} }
} }

View File

@ -4,6 +4,8 @@ CREATE TABLE `campaigns` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT, `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL, `title` VARCHAR(255) NOT NULL,
`description` TEXT, `description` TEXT,
`link` VARCHAR(255) NULL,
`slogan` VARCHAR(255) NULL,
`goal_amount` DECIMAL(15,2) NOT NULL, `goal_amount` DECIMAL(15,2) NOT NULL,
`raised_amount` DECIMAL(15,2) DEFAULT 0, `raised_amount` DECIMAL(15,2) DEFAULT 0,
`status` VARCHAR(50) NOT NULL, `status` VARCHAR(50) NOT NULL,

View File

@ -17,7 +17,7 @@ func New(db *mysql.DB) *DB {
} }
// CreateCampaign creates a new campaign // CreateCampaign creates a new campaign
func (d *DB) CreateAndSave(ctx context.Context, campaign entity.Campaign) (types.ID, error) { func (d *DB) Create(ctx context.Context, campaign entity.Campaign) (types.ID, error) {
const Op = "repository.mysql.campaign.create" const Op = "repository.mysql.campaign.create"
tx, err := d.conn.Conn().BeginTx(ctx, nil) tx, err := d.conn.Conn().BeginTx(ctx, nil)
@ -26,13 +26,16 @@ 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 campaigns (title, description,link, slogan ,
goal_amount, raised_amount,
status, deadline_at ,admin_id , 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, campaign.Title,
campaign.Description, campaign.Description,
campaign.Link,
campaign.Slogan,
campaign.GoalAmount, campaign.GoalAmount,
campaign.RaisedAmount, campaign.RaisedAmount,
campaign.Status, campaign.Status,

View File

@ -9,24 +9,30 @@ import (
"time" "time"
) )
func ToCampaignEntity(req CreateCampaignRequest) entity.Campaign {
return entity.Campaign{
Title: req.Title,
Description: req.Description,
Link: req.Link,
Slogan: req.Slogan,
GoalAmount: req.GoalAmount,
RaisedAmount: 0,
Status: types.CampaignStatus(req.Status),
DeadlineAt: req.DeadlineAt,
AdminID: req.AdminID,
CreatedAt: time.Now(),
}
}
// CreateCampaign handles creation of a new campaign. // CreateCampaign handles creation of a new campaign.
func (s *CampaignService) CreateCampaign(ctx context.Context, req entity.Campaign) (types.ID, error) { func (s *CampaignService) CreateCampaign(ctx context.Context, req CreateCampaignRequest) (types.ID, error) {
const op = "service.campaign.create_campaign" const op = "service.campaign.create_campaign"
if err := validateCreateCampaignRequest(req); err != nil { if err := validateCreateCampaignRequest(req); err != nil {
return 0, richerror.New(op).WithErr(err) return 0, richerror.New(op).WithErr(err)
} }
campaign := entity.Campaign{ campaign := ToCampaignEntity(req)
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) id, err := s.repo.Create(ctx, campaign)
if err != nil { if err != nil {
@ -36,7 +42,7 @@ func (s *CampaignService) CreateCampaign(ctx context.Context, req entity.Campaig
return id, nil return id, nil
} }
func validateCreateCampaignRequest(req entity.Campaign) error { func validateCreateCampaignRequest(req CreateCampaignRequest) error {
if req.Title == "" { if req.Title == "" {
return errRequired("title") return errRequired("title")
} }

View File

@ -6,22 +6,25 @@ import (
) )
type GetCampaignResponse struct { type GetCampaignResponse struct {
ID types.ID `json:"user_id"` ID types.ID `json:"campaign_id"`
} }
type AddCampaignRequest struct { type CreateCampaignRequest struct {
ID uint64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Link string `json:"link"`
Slogan string `json:"slogan" validate:"max=255"`
GoalAmount float64 `json:"goal_amount"` GoalAmount float64 `json:"goal_amount"`
Status string `json:"status,omitempty"`
DeadlineAt *time.Time `json:"deadline_at,omitempty"` DeadlineAt *time.Time `json:"deadline_at,omitempty"`
AdminID types.ID `json:"admin_id"` AdminID types.ID `json:"admin_id" validate:"required"`
} }
type UpdateCampaignRequest struct { type CompletedCampaignResponse struct {
Title *string `json:"title,omitempty"` TotalChecked uint64 `json:"total_checked"`
Description *string `json:"description,omitempty"` TotalFinished uint64 `json:"total_finished"`
GoalAmount *float64 `json:"goal_amount,omitempty"` }
DeadlineAt *time.Time `json:"deadline_at,omitempty"`
Status *string `json:"status,omitempty"` // draft/active/completed/paused/cancelled type FilterRequest struct {
Limit uint32 `json:"total_checked"`
} }

View File

@ -14,8 +14,13 @@ type CampaignFilterParam struct {
IsArchived *bool IsArchived *bool
} }
type CampaignStatus interface {
FindActiveCampaigns(ctx context.Context) ([]entity.Campaign, error)
UpdateStatus(ctx context.Context, id types.ID, status types.CampaignStatus) error
}
type CampaignStorage interface { type CampaignStorage interface {
Create(ctx context.Context, c entity.Campaign) (types.ID, error) // باید ID برگرداند Create(ctx context.Context, c entity.Campaign) (types.ID, error)
Update(ctx context.Context, c entity.Campaign) error Update(ctx context.Context, c entity.Campaign) error
FindByID(ctx context.Context, id types.ID) (entity.Campaign, error) FindByID(ctx context.Context, id types.ID) (entity.Campaign, error)
FindAll(ctx context.Context, filter CampaignFilterParam) ([]entity.Campaign, error) FindAll(ctx context.Context, filter CampaignFilterParam) ([]entity.Campaign, error)
@ -24,10 +29,10 @@ type CampaignStorage interface {
} }
type CampaignService struct { type CampaignService struct {
repo CampaignStorage repo CampaignStorage
repoStatus CampaignStatus
} }
// NewCampaignService constructs a new CampaignService.
func NewCampaignService(storage CampaignStorage) *CampaignService { func NewCampaignService(storage CampaignStorage) *CampaignService {
return &CampaignService{ return &CampaignService{
repo: storage, repo: storage,

View File

@ -0,0 +1,64 @@
package service
import (
"context"
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
func (s *CampaignService) MonitorCampaignProgress(ctx context.Context, req FilterRequest) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.CheckAndCompleteCampaigns(ctx, req)
case <-ctx.Done():
return
}
}
}
func (s *CampaignService) CheckAndCompleteCampaigns(ctx context.Context, req FilterRequest) (CompletedCampaignResponse, error) {
now := time.Now()
//TODO:with filter request later complete
activeCampaigns, err := s.repoStatus.FindActiveCampaigns(ctx)
if err != nil {
return CompletedCampaignResponse{}, err
}
var totalChecked uint64
var totalFinished uint64
for _, campaign := range activeCampaigns {
totalChecked++
shouldFinish := false
if campaign.DeadlineAt != nil && campaign.DeadlineAt.Before(now) {
shouldFinish = true
}
if campaign.RaisedAmount >= campaign.GoalAmount {
shouldFinish = true
}
if shouldFinish && campaign.Status != types.CampaignFinished {
if err := s.repoStatus.UpdateStatus(ctx, campaign.ID, types.CampaignFinished); err != nil {
continue
}
totalFinished++
}
}
return CompletedCampaignResponse{
TotalChecked: totalChecked,
TotalFinished: totalFinished,
}, nil
}

View File

@ -1,8 +1,5 @@
package donate_app package donate_app
import ( import (
"git.gocasts.ir/ebhomengo/niki/repository/mysql" "git.gocasts.ir/ebhomengo/niki/repository/mysql"
) )
@ -10,8 +7,3 @@ import (
type Config struct { type Config struct {
Mysql mysql.Config `koanf:"mariadb"` Mysql mysql.Config `koanf:"mariadb"`
} }
type Config struct{
}

View File

@ -1,12 +1,13 @@
package http package http
import ( import (
"git.gocasts.ir/ebhomengo/niki/domain/campaign/entity"
"git.gocasts.ir/ebhomengo/niki/domain/campaign/service" "git.gocasts.ir/ebhomengo/niki/domain/campaign/service"
param "git.gocasts.ir/ebhomengo/niki/domain/campaign/service"
"git.gocasts.ir/ebhomengo/niki/pkg/claim"
httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg" httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg"
"git.gocasts.ir/ebhomengo/niki/types"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"net/http" "net/http"
"time"
) )
type Handler struct { type Handler struct {
@ -19,14 +20,16 @@ func NewHandler(svc service.CampaignService) Handler {
func (h Handler) createCampaign(c echo.Context) error { func (h Handler) createCampaign(c echo.Context) error {
var req entity.Campaign claims := claim.GetClaimsFromEchoContext(c)
var req param.CreateCampaignRequest
req.AdminID = types.ID(claims.UserID)
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{ return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid request body", "error": "invalid request body",
}) })
} }
req.CreatedAt = time.Now()
req.RaisedAmount = 0
createdID, err := h.svc.CreateCampaign(c.Request().Context(), req) createdID, err := h.svc.CreateCampaign(c.Request().Context(), req)
if err != nil { if err != nil {

View File

@ -1,4 +0,0 @@
// --- Type Aliases ---
package pkg
type ID uint64

View File

@ -0,0 +1,22 @@
syntax = "proto3";
package campaign;
option go_package = "git.gocasts.ir/ebhomengo/niki/donate_app/protobuf;campaignpb";
service CampaignService {
rpc CheckAndCompleteCampaigns (CheckAndCompleteCampaignsRequest) returns (CheckAndCompleteCampaignsResponse);
}
message CheckAndCompleteCampaignsRequest {
FilterRequest filter = 1;
}
message CheckAndCompleteCampaignsResponse {
uint64 total_checked = 1;
uint64 total_finished = 2;
}
message FilterRequest {
uint32 limit = 1;
}

View File

@ -1,19 +0,0 @@
-- +migrate Up
CREATE TABLE `campaign_participants` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`campaign_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(15,2) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`campaign_id`)
REFERENCES `campaigns`(`id`)
ON DELETE CASCADE,
FOREIGN KEY (`user_id`)
REFERENCES `users`(`id`)
ON DELETE CASCADE,
UNIQUE KEY `unique_campaign_user` (`campaign_id`, `user_id`)
);
-- +migrate Down
DROP TABLE `campaign_participants`;

View File

@ -0,0 +1,28 @@
-- +migrate Up
CREATE TABLE `donation_flows` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`campaign_id` BIGINT NOT NULL,
`user_id` BIGINT NULL,
`source_type` VARCHAR(50) NOT NULL, -- e.g., "affiliate", "app", "qr", "sms"
`source_name` VARCHAR(100) NOT NULL, -- e.g., "instagram", "donate_app"
`referral_code` VARCHAR(100),
`link` VARCHAR(255) NOT NULL,
`clicks` BIGINT DEFAULT 0,
`conversions` BIGINT DEFAULT 0,
`donations_total` DECIMAL(15,2) DEFAULT 0.00,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`campaign_id`)
REFERENCES `campaigns`(`id`)
ON DELETE CASCADE,
FOREIGN KEY (`user_id`)
REFERENCES `users`(`id`)
ON DELETE SET NULL
);
CREATE INDEX `idx_flow_campaign_id` ON `donation_flows`(`campaign_id`);
CREATE INDEX `idx_flow_source` ON `donation_flows`(`source_type`, `source_name`);
-- +migrate Down
DROP TABLE IF EXISTS `donation_flows`;

View File

@ -1,11 +1,7 @@
package mysql package mysql
import ( import (
"context"
"git.gocasts.ir/ebhomengo/niki/donate_app/service/entity"
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"
) )
type DB struct { type DB struct {
@ -15,40 +11,3 @@ type DB struct {
func New(db *mysql.DB) *DB { 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) {
const Op = "repository.mysql.campaign_participant.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 campaign_participants (id,campaign_id, user_id, amount , created_at)
VALUES (?, ?, ? , ? , NOW())`
result, err := tx.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)
}
if err := tx.Commit(); err != nil {
return 0, richerror.New(Op).WithErr(err)
}
return types.ID(id), nil
}

View File

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

View File

@ -0,0 +1,20 @@
package entity
import (
"git.gocasts.ir/ebhomengo/niki/types"
"time"
)
type DonationApp struct {
ID types.ID `json:"id"`
CampaignID types.ID `json:"campaign_id"`
UserID *types.ID `json:"user_id,omitempty"`
SourceType string `json:"source_type"` // : "affiliate", "app", "qr", "sms", "social"
SourceName string `json:"source_name"` // "user_name", "donate_app", "instagram"
ReferralCode string `json:"referral_code"`
Link string `json:"link"`
Clicks int64 `json:"clicks"`
Conversions int64 `json:"conversions"`
DonationsTotal float64 `json:"donations_total"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -1,26 +0,0 @@
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 +0,0 @@
package service

View File

@ -1 +0,0 @@
package service

11
types/status.go Normal file
View File

@ -0,0 +1,11 @@
package types
type CampaignStatus string
const (
CampaignDraft CampaignStatus = "draft"
CampaignActive CampaignStatus = "active"
CampaignFinished CampaignStatus = "completed"
CampaignPaused CampaignStatus = "paused"
CampaignCanceled CampaignStatus = "cancelled"
)