diff --git a/.gitignore b/.gitignore index 07831c8..fb1a325 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ docker-compose.yaml docs/node_modules docs/web_deploy vendor/ +configs \ No newline at end of file diff --git a/README.md b/README.md index 0f909c6..7aa2574 100644 --- a/README.md +++ b/README.md @@ -42,20 +42,23 @@ Body: ```json { "nullifier": "0x0000000000000000000000000000000000000000000000000000000000000000", - "count": 2 + "count": 2, + "infinity": true } ``` Response: ```json { "referral": "kPRQYQUcWzW", - "usage_left": 2 + "usage_left": 2, + "infinity": true } ``` Parameters: - `nullifier` - nullifier to create or edit referrals for - `count` - number of referral usage +- `infinity` - specify if referrals code have unlimited usage count ### Local build diff --git a/config.yaml b/config.yaml index bfa2a3a..168dba8 100644 --- a/config.yaml +++ b/config.yaml @@ -60,6 +60,9 @@ levels: - lvl: 1 threshold: 0 referrals: 1 + - lvl: 2 + threshold: 10 + infinity: true auth: addr: http://rarime-auth diff --git a/docs/spec/components/schemas/Balance.yaml b/docs/spec/components/schemas/Balance.yaml index fd35a7d..f58b63e 100644 --- a/docs/spec/components/schemas/Balance.yaml +++ b/docs/spec/components/schemas/Balance.yaml @@ -37,10 +37,11 @@ allOf: format: int description: Rank of the user in the full leaderboard. Returned only for the single user. example: 294 - referral_code: - type: string - description: User referral code. Returned only for the single user. - example: "6xM70VgX4eh" + referral_codes: + type: array + description: Referral codes. Returned only for the single user. + items: + $ref: '#/components/schemas/ReferralCode' level: type: integer format: int diff --git a/docs/spec/components/schemas/ReferralCode.yaml b/docs/spec/components/schemas/ReferralCode.yaml new file mode 100644 index 0000000..8369fc6 --- /dev/null +++ b/docs/spec/components/schemas/ReferralCode.yaml @@ -0,0 +1,28 @@ +type: object +required: + - id + - status +properties: + id: + type: string + description: Referral code itself, unique identifier + example: "bDSCcQB8Hhk" + status: + type: string + description: | + Status of the code, belonging to this user (referrer): + 1. infinity: the code have unlimited usage count and user can get points for each user who scanned passport + 2. active: the code is not used yet by another user (referee) + 3. awaiting: the code is used by referee who has scanned passport, but the referrer hasn't yet + 4. rewarded: the code is used, both referee and referrer have scanned passports + 5. consumed: the code is used by referee who has not scanned passport yet + + The list is sorted by priority. E.g. if the referee has scanned passport, + but referrer not, the status would be `consumed`. If both not scann passport yet + status would be `awaiting`. + enum: + - infinity + - active + - awaiting + - rewarded + - consumed diff --git a/internal/assets/migrations/001_initial.sql b/internal/assets/migrations/001_initial.sql index f227975..4d386ce 100644 --- a/internal/assets/migrations/001_initial.sql +++ b/internal/assets/migrations/001_initial.sql @@ -29,7 +29,8 @@ CREATE TABLE IF NOT EXISTS referrals ( id text PRIMARY KEY, nullifier TEXT NOT NULL REFERENCES balances (nullifier), - usage_left INTEGER NOT NULL DEFAULT 1 + usage_left INTEGER NOT NULL DEFAULT 1, + infinity BOOLEAN NOT NULL DEFAULT FALSE ); CREATE INDEX IF NOT EXISTS referrals_nullifier_index ON referrals (nullifier); diff --git a/internal/config/levels.go b/internal/config/levels.go index 5541e28..9abccc0 100644 --- a/internal/config/levels.go +++ b/internal/config/levels.go @@ -10,9 +10,10 @@ import ( ) type Level struct { - Level int `fig:"lvl,required"` - Threshold int `fig:"threshold,required"` - Referrals int `fig:"referrals,required"` + Level int `fig:"lvl,required"` + Threshold int `fig:"threshold,required"` + Referrals int `fig:"referrals"` + Infinity bool `fig:"infinity"` } type Levels map[int]Level @@ -63,6 +64,9 @@ func (l Levels) LvlUp(currentLevel int, totalAmount int64) (refCoundToAdd int, n } newLevel = slices.Max(lvls) + if l[newLevel].Infinity { + refCoundToAdd = -1 + } return } diff --git a/internal/data/pg/referrals.go b/internal/data/pg/referrals.go index ea2c00c..12445c6 100644 --- a/internal/data/pg/referrals.go +++ b/internal/data/pg/referrals.go @@ -39,9 +39,9 @@ func (q *referrals) Insert(referrals ...data.Referral) error { return nil } - stmt := squirrel.Insert(referralsTable).Columns("id", "nullifier", "usage_left") + stmt := squirrel.Insert(referralsTable).Columns("id", "nullifier", "usage_left", "infinity") for _, ref := range referrals { - stmt = stmt.Values(ref.ID, ref.Nullifier, ref.UsageLeft) + stmt = stmt.Values(ref.ID, ref.Nullifier, ref.UsageLeft, ref.Infinity) } if err := q.db.Exec(stmt); err != nil { @@ -51,10 +51,13 @@ func (q *referrals) Insert(referrals ...data.Referral) error { return nil } -func (q *referrals) Update(usageLeft int) (*data.Referral, error) { +func (q *referrals) Update(usageLeft int, infinity bool) (*data.Referral, error) { var res data.Referral - if err := q.db.Get(&res, q.updater.Set("usage_left", usageLeft).Suffix("RETURNING *")); err != nil { + if err := q.db.Get(&res, q.updater.SetMap(map[string]interface{}{ + "usage_left": usageLeft, + "infinity": infinity, + }).Suffix("RETURNING *")); err != nil { return nil, fmt.Errorf("update referral: %w", err) } @@ -96,10 +99,48 @@ func (q *referrals) Count() (uint64, error) { return res.Count, nil } +func (q *referrals) WithStatus() data.ReferralsQ { + var ( + joinReferrer = fmt.Sprintf("JOIN %s rr ON %s.nullifier = rr.nullifier", balancesTable, referralsTable) + joinReferee = fmt.Sprintf("LEFT JOIN %s re ON %s.id = re.referred_by", balancesTable, referralsTable) + + status = fmt.Sprintf(`CASE + WHEN infinity = TRUE THEN '%s' + WHEN usage_left > 0 THEN '%s' + WHEN rr.is_verified = FALSE AND re.is_verified = TRUE THEN '%s' + WHEN rr.is_verified = TRUE AND re.is_verified = TRUE THEN '%s' + ELSE '%s' + END AS status`, + data.StatusInfinity, data.StatusActive, data.StatusAwaiting, + data.StatusRewarded, data.StatusConsumed, + ) + ) + + q.selector = q.selector.Column(status). + JoinClause(joinReferrer). + JoinClause(joinReferee) + + return q +} + +func (q *referrals) Consume(id string) error { + stmt := q.consumer.Where(squirrel.Eq{"id": id}) + + if err := q.db.Exec(stmt); err != nil { + return fmt.Errorf("consume referral [%v]: %w", id, err) + } + + return nil +} + func (q *referrals) FilterByNullifier(nullifier string) data.ReferralsQ { return q.applyCondition(squirrel.Eq{fmt.Sprintf("%s.nullifier", referralsTable): nullifier}) } +func (q *referrals) FilterInactive() data.ReferralsQ { + return q.applyCondition(squirrel.Or{squirrel.Gt{fmt.Sprintf("%s.usage_left", referralsTable): 0}, squirrel.Eq{fmt.Sprintf("%s.infinity", referralsTable): true}}) +} + func (q *referrals) applyCondition(cond squirrel.Sqlizer) data.ReferralsQ { q.selector = q.selector.Where(cond) q.consumer = q.consumer.Where(cond) diff --git a/internal/data/referrals.go b/internal/data/referrals.go index 231988e..8a31326 100644 --- a/internal/data/referrals.go +++ b/internal/data/referrals.go @@ -1,9 +1,19 @@ package data +const ( + StatusInfinity = "infinity" + StatusActive = "active" + StatusAwaiting = "awaiting" + StatusRewarded = "rewarded" + StatusConsumed = "consumed" +) + type Referral struct { ID string `db:"id"` Nullifier string `db:"nullifier"` UsageLeft int32 `db:"usage_left"` + Infinity bool `db:"infinity"` + Status string `db:"status"` } type ReferralsQ interface { @@ -13,8 +23,11 @@ type ReferralsQ interface { Select() ([]Referral, error) Get(id string) (*Referral, error) Count() (uint64, error) + Consume(id string) error - Update(usageLeft int) (*Referral, error) + WithStatus() ReferralsQ + Update(usageLeft int, infinity bool) (*Referral, error) FilterByNullifier(string) ReferralsQ + FilterInactive() ReferralsQ } diff --git a/internal/service/handlers/claim_event.go b/internal/service/handlers/claim_event.go index 1ff7439..840c719 100644 --- a/internal/service/handlers/claim_event.go +++ b/internal/service/handlers/claim_event.go @@ -9,6 +9,7 @@ import ( "github.com/rarimo/geo-points-svc/internal/data" "github.com/rarimo/geo-points-svc/internal/data/evtypes" "github.com/rarimo/geo-points-svc/internal/data/pg" + "github.com/rarimo/geo-points-svc/internal/service/referralid" "github.com/rarimo/geo-points-svc/internal/service/requests" "github.com/rarimo/geo-points-svc/resources" "gitlab.com/distributed_lab/ape" @@ -141,16 +142,26 @@ func DoClaimEventUpdates( func doLvlUpAndReferralsUpdate(levels config.Levels, referralsQ data.ReferralsQ, balance data.Balance, reward int64) (level int, err error) { refsCount, level := levels.LvlUp(balance.Level, reward+balance.Amount) - if refsCount > 0 { - count, err := referralsQ.New().FilterByNullifier(balance.Nullifier).Count() - if err != nil { - return 0, fmt.Errorf("failed to get referral count: %w", err) - } + // we need +2 because refsCount can be -1 + referrals := make([]data.Referral, 0, refsCount+2) - refToAdd := prepareReferralsToAdd(balance.Nullifier, uint64(refsCount), count) - if err = referralsQ.New().Insert(refToAdd...); err != nil { - return 0, fmt.Errorf("failed to insert referrals: %w", err) - } + // count used to calculate ref code + count, err := referralsQ.New().FilterByNullifier(balance.Nullifier).Count() + if err != nil { + return 0, fmt.Errorf("failed to get referral count: %w", err) + } + switch { + case refsCount > 0: + referrals = append(referrals, prepareReferralsToAdd(balance.Nullifier, uint64(refsCount), count)...) + case refsCount == -1: + referrals = append(referrals, data.Referral{ + ID: referralid.New(balance.Nullifier, count), + Nullifier: balance.Nullifier, + Infinity: true, + }) + } + if err = referralsQ.New().Insert(referrals...); err != nil { + return 0, fmt.Errorf("failed to insert referrals: %w", err) } return level, nil diff --git a/internal/service/handlers/create_balance.go b/internal/service/handlers/create_balance.go index a18ae1f..efb61f0 100644 --- a/internal/service/handlers/create_balance.go +++ b/internal/service/handlers/create_balance.go @@ -39,7 +39,7 @@ func CreateBalance(w http.ResponseWriter, r *http.Request) { return } - referral, err := ReferralsQ(r).Get(req.Data.Attributes.ReferredBy) // infinite referrals allowed + referral, err := ReferralsQ(r).FilterInactive().Get(req.Data.Attributes.ReferredBy) if err != nil { Log(r).WithError(err).Error("Failed to get referral by ID") ape.RenderErr(w, problems.InternalError()) @@ -76,19 +76,17 @@ func CreateBalance(w http.ResponseWriter, r *http.Request) { return } - referrals, err := ReferralsQ(r).FilterByNullifier(nullifier).Select() + referrals, err := ReferralsQ(r). + FilterByNullifier(nullifier). + WithStatus(). + Select() if err != nil { - Log(r).WithError(err).Error("Failed to get referral code by nullifier") - ape.RenderErr(w, problems.InternalError()) - return - } - if len(referrals) != 1 { - Log(r).WithError(err).Error("There must be only 1 referral code") + Log(r).WithError(err).Error("Failed to get referrals by nullifier with rewarding field") ape.RenderErr(w, problems.InternalError()) return } - ape.Render(w, newBalanceResponse(*balance, &referrals[0])) + ape.Render(w, newBalanceResponse(*balance, referrals)) } func prepareEventsWithRef(nullifier, refBy string, isGenesisRef bool, r *http.Request) []data.Event { @@ -161,6 +159,14 @@ func createBalanceWithEventsAndReferrals(nullifier string, refBy *string, events return fmt.Errorf("update balance amount and level: %w", err) } + if refBy == nil { + return nil + } + + if err = ReferralsQ(r).Consume(*refBy); err != nil { + return fmt.Errorf("failed to consume referral") + } + return nil }) } diff --git a/internal/service/handlers/edit_referrals.go b/internal/service/handlers/edit_referrals.go index 458cfc9..daf7893 100644 --- a/internal/service/handlers/edit_referrals.go +++ b/internal/service/handlers/edit_referrals.go @@ -45,6 +45,7 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { ID: code, Nullifier: req.Nullifier, UsageLeft: int32(req.Count), + Infinity: req.Infinity, }) if err != nil { return fmt.Errorf("failed to insert referral for nullifier [%s]: %w", req.Nullifier, err) @@ -61,7 +62,8 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { ape.Render(w, struct { Ref string `json:"referral"` UsageLeft uint64 `json:"usage_left"` - }{code, req.Count}) + Infinity bool `json:"infinity"` + }{code, req.Count, req.Infinity}) return } @@ -82,7 +84,7 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { return } - referral, err := ReferralsQ(r).FilterByNullifier(req.Nullifier).Update(int(req.Count)) + referral, err := ReferralsQ(r).FilterByNullifier(req.Nullifier).Update(int(req.Count), req.Infinity) if err != nil { Log(r).WithError(err).Errorf("failed to update referral usage count for nullifier [%s]", req.Nullifier) ape.RenderErr(w, problems.InternalError()) @@ -97,9 +99,11 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { ape.Render(w, struct { Ref string `json:"referral"` UsageLeft uint64 `json:"usage_left"` + Infinity bool `json:"infinity"` }{ referral.ID, uint64(referral.UsageLeft), + req.Infinity, }) } diff --git a/internal/service/handlers/get_balance.go b/internal/service/handlers/get_balance.go index cb6dc0e..834ec45 100644 --- a/internal/service/handlers/get_balance.go +++ b/internal/service/handlers/get_balance.go @@ -41,23 +41,20 @@ func GetBalance(w http.ResponseWriter, r *http.Request) { return } - var referral *data.Referral + var referrals []data.Referral if req.ReferralCodes { - referrals, err := ReferralsQ(r).FilterByNullifier(req.Nullifier).Select() + referrals, err = ReferralsQ(r). + FilterByNullifier(req.Nullifier). + WithStatus(). + Select() if err != nil { - Log(r).WithError(err).Error("Failed to get referral code by nullifier") + Log(r).WithError(err).Error("Failed to get referrals by nullifier with rewarding field") ape.RenderErr(w, problems.InternalError()) return } - if len(referrals) != 1 { - Log(r).WithError(err).Error("There must be exactly 1 referral code") - ape.RenderErr(w, problems.InternalError()) - return - } - referral = &referrals[0] } - ape.Render(w, newBalanceResponse(*balance, referral)) + ape.Render(w, newBalanceResponse(*balance, referrals)) } // newBalanceModel forms a balance response without referral fields, which must @@ -78,15 +75,25 @@ func newBalanceModel(balance data.Balance) resources.Balance { } } -func newBalanceResponse(balance data.Balance, referral *data.Referral) resources.BalanceResponse { +func newBalanceResponse(balance data.Balance, referrals []data.Referral) resources.BalanceResponse { resp := resources.BalanceResponse{Data: newBalanceModel(balance)} boolP := func(b bool) *bool { return &b } resp.Data.Attributes.IsDisabled = boolP(balance.ReferredBy == nil) resp.Data.Attributes.IsVerified = boolP(balance.IsVerified) - if referral != nil { - resp.Data.Attributes.ReferralCode = &referral.ID + if len(referrals) == 0 { + return resp } + + res := make([]resources.ReferralCode, len(referrals)) + for i, r := range referrals { + res[i] = resources.ReferralCode{ + Id: r.ID, + Status: r.Status, + } + } + + resp.Data.Attributes.ReferralCodes = &res return resp } diff --git a/internal/service/requests/edit_referrals.go b/internal/service/requests/edit_referrals.go index d3491f6..42d2bf3 100644 --- a/internal/service/requests/edit_referrals.go +++ b/internal/service/requests/edit_referrals.go @@ -11,6 +11,7 @@ import ( type EditReferralsRequest struct { Nullifier string `json:"nullifier"` Count uint64 `json:"count"` + Infinity bool `json:"infinity"` } func NewEditReferrals(r *http.Request) (req EditReferralsRequest, err error) { diff --git a/resources/model_balance_attributes.go b/resources/model_balance_attributes.go index f522fec..dfd4066 100644 --- a/resources/model_balance_attributes.go +++ b/resources/model_balance_attributes.go @@ -17,8 +17,8 @@ type BalanceAttributes struct { Level int `json:"level"` // Rank of the user in the full leaderboard. Returned only for the single user. Rank *int `json:"rank,omitempty"` - // User referral code. Returned only for the single user. - ReferralCode *string `json:"referral_code,omitempty"` + // Referral codes. Returned only for the single user. + ReferralCodes *[]ReferralCode `json:"referral_codes,omitempty"` // Unix timestamp of the last points accruing UpdatedAt int32 `json:"updated_at"` } diff --git a/resources/model_referral_code.go b/resources/model_referral_code.go new file mode 100644 index 0000000..053e24f --- /dev/null +++ b/resources/model_referral_code.go @@ -0,0 +1,12 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +type ReferralCode struct { + // Referral code itself, unique identifier + Id string `json:"id"` + // Status of the code, belonging to this user (referrer): 1. infinity: the code have unlimited usage count and user can get points for each user who scanned passport 2. active: the code is not used yet by another user (referee) 3. awaiting: the code is used by referee who has scanned passport, but the referrer hasn't yet 4. rewarded: the code is used, both referee and referrer have scanned passports 5. consumed: the code is used by referee who has not scanned passport yet The list is sorted by priority. E.g. if the referee has scanned passport, but referrer not, the status would be `consumed`. If both not scann passport yet status would be `awaiting`. + Status string `json:"status"` +}