diff --git a/docs/spec/components/schemas/PassportEventState.yaml b/docs/spec/components/schemas/PassportEventState.yaml new file mode 100644 index 0000000..4fd519d --- /dev/null +++ b/docs/spec/components/schemas/PassportEventState.yaml @@ -0,0 +1,15 @@ +allOf: + - $ref: '#/components/schemas/PassportEventStateKey' + - type: object + required: + - attributes + properties: + attributes: + required: + - claimed + type: object + properties: + claimed: + type: bool + example: true + description: If passport scan event was automatically claimed \ No newline at end of file diff --git a/docs/spec/components/schemas/PassportEventStateKey.yaml b/docs/spec/components/schemas/PassportEventStateKey.yaml new file mode 100644 index 0000000..d630d08 --- /dev/null +++ b/docs/spec/components/schemas/PassportEventStateKey.yaml @@ -0,0 +1,13 @@ +type: object +required: + - id + - type +properties: + id: + type: string + description: Nullifier of the points owner + example: "0x123...abc" + pattern: '^0x[0-9a-fA-F]{64}$' + type: + type: string + enum: [ passport_event_state ] diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml index 6b0d624..ab94a46 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{nullifier}@verifypassport.yaml @@ -19,8 +19,17 @@ post: data: $ref: '#/components/schemas/VerifyPassport' responses: - 204: + 200: description: Success + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/PassportEventState' 400: $ref: '#/components/responses/invalidParameter' 401: diff --git a/internal/data/balances.go b/internal/data/balances.go index deed63c..92808a5 100644 --- a/internal/data/balances.go +++ b/internal/data/balances.go @@ -36,17 +36,20 @@ type BalancesQ interface { GetWithRank(nullifier string) (*Balance, error) SelectWithRank() ([]Balance, error) - // Filters not applied. Return balances which already have scanned passport, but there no fulfilled or claimed events for this + // WithoutPassportEvent returns balances which already + // have scanned passport, but there no claimed events + // for this. Filters are not applied. WithoutPassportEvent() ([]WithoutPassportEventBalance, error) WithoutReferralEvent() ([]ReferredReferrer, error) - FilterByNullifier(string) BalancesQ + FilterByNullifier(...string) BalancesQ FilterDisabled() BalancesQ } type WithoutPassportEventBalance struct { Balance - EventID *string `db:"event_id"` + EventID string `db:"event_id"` + EventStatus EventStatus `db:"event_status"` } type ReferredReferrer struct { diff --git a/internal/data/evtypes/main.go b/internal/data/evtypes/main.go index 3aebc78..44e7939 100644 --- a/internal/data/evtypes/main.go +++ b/internal/data/evtypes/main.go @@ -44,6 +44,7 @@ type EventConfig struct { StartsAt *time.Time `fig:"starts_at"` ExpiresAt *time.Time `fig:"expires_at"` NoAutoOpen bool `fig:"no_auto_open"` + AutoClaim bool `fig:"auto_claim"` Disabled bool `fig:"disabled"` ActionURL *url.URL `fig:"action_url"` Logo *url.URL `fig:"logo"` diff --git a/internal/data/pg/balances.go b/internal/data/pg/balances.go index 5dbfcc6..74ba933 100644 --- a/internal/data/pg/balances.go +++ b/internal/data/pg/balances.go @@ -145,10 +145,10 @@ func (q *balances) GetWithRank(nullifier string) (*data.Balance, error) { func (q *balances) WithoutPassportEvent() ([]data.WithoutPassportEventBalance, error) { var res []data.WithoutPassportEventBalance stmt := fmt.Sprintf(` - SELECT b.*, e.id AS event_id - FROM %s AS b LEFT JOIN %s AS e + SELECT b.*, e.id AS event_id, e.status AS event_status + FROM %s AS b INNER JOIN %s AS e ON b.nullifier = e.nullifier AND e.type='passport_scan' - WHERE (e.status NOT IN ('fulfilled', 'claimed') OR e.nullifier IS NULL) + WHERE e.status NOT IN ('claimed') AND b.referred_by IS NOT NULL AND b.country IS NOT NULL `, balancesTable, eventsTable) @@ -188,7 +188,7 @@ func (q *balances) WithoutReferralEvent() ([]data.ReferredReferrer, error) { } -func (q *balances) FilterByNullifier(nullifier string) data.BalancesQ { +func (q *balances) FilterByNullifier(nullifier ...string) data.BalancesQ { return q.applyCondition(squirrel.Eq{"nullifier": nullifier}) } diff --git a/internal/service/handlers/claim_event.go b/internal/service/handlers/claim_event.go index 725555d..30eab5d 100644 --- a/internal/service/handlers/claim_event.go +++ b/internal/service/handlers/claim_event.go @@ -5,7 +5,9 @@ import ( "net/http" "github.com/rarimo/decentralized-auth-svc/pkg/auth" + "github.com/rarimo/rarime-points-svc/internal/config" "github.com/rarimo/rarime-points-svc/internal/data" + "github.com/rarimo/rarime-points-svc/internal/data/evtypes" "github.com/rarimo/rarime-points-svc/internal/data/pg" "github.com/rarimo/rarime-points-svc/internal/service/requests" "github.com/rarimo/rarime-points-svc/resources" @@ -82,7 +84,13 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { return } - event, err = claimEventWithPoints(r, *event, evType.Reward, balance) + err = EventsQ(r).Transaction(func() error { + event, err = claimEvent(r, event, balance) + if err != nil { + return err + } + return nil + }) if err != nil { Log(r).WithError(err).Errorf("Failed to claim event %s and accrue %d points to the balance %s", event.ID, evType.Reward, event.Nullifier) @@ -101,53 +109,82 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { ape.Render(w, newClaimEventResponse(*event, evType.Resource(), *balance)) } -// claimEventWithPoints requires event to exist -func claimEventWithPoints(r *http.Request, event data.Event, reward int64, balance *data.Balance) (claimed *data.Event, err error) { - err = EventsQ(r).Transaction(func() error { - // Upgrade level logic when threshold is reached - refsCount, level := Levels(r).LvlUp(balance.Level, reward+balance.Amount) - if level != balance.Level { - count, err := ReferralsQ(r).FilterByNullifier(balance.Nullifier).Count() - if err != nil { - return fmt.Errorf("failed to get referral count: %w", err) - } - - refToAdd := prepareReferralsToAdd(balance.Nullifier, uint64(refsCount), count) - if err = ReferralsQ(r).Insert(refToAdd...); err != nil { - return fmt.Errorf("failed to insert referrals: %w", err) - } - - err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(map[string]any{ - data.ColLevel: level, - }) - if err != nil { - return fmt.Errorf("failed to update level: %w", err) - } - } +// claimEvent requires event to exist +// call in transaction to prevent unexpected changes +func claimEvent(r *http.Request, event *data.Event, balance *data.Balance) (claimed *data.Event, err error) { + if event == nil { + return nil, nil + } - claimed, err = EventsQ(r).FilterByID(event.ID).Update(data.EventClaimed, nil, &reward) - if err != nil { - return fmt.Errorf("update event status: %w", err) - } + evType := EventTypes(r).Get(event.Type, evtypes.FilterInactive) + if evType == nil { + return event, nil + } + + claimed, err = EventsQ(r).FilterByID(event.ID).Update(data.EventClaimed, nil, &evType.Reward) + if err != nil { + return nil, fmt.Errorf("update event status: %w", err) + } + + err = DoClaimEventUpdates( + Levels(r), + ReferralsQ(r), + BalancesQ(r), + CountriesQ(r), + *balance, + evType.Reward) + if err != nil { + return nil, fmt.Errorf("failed to do claim event updates: %w", err) + } + + return claimed, nil +} - err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(map[string]any{ - data.ColAmount: pg.AddToValue(data.ColAmount, reward), - }) +// DoClaimEventUpdates do updates which link to claim event: +// update reserved amount in country; +// lvlup and update referrals count; +// accruing points; +// +// Balance must be active and with verified passport +func DoClaimEventUpdates( + levels config.Levels, + referralsQ data.ReferralsQ, + balancesQ data.BalancesQ, + countriesQ data.CountriesQ, + balance data.Balance, + reward int64) (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 fmt.Errorf("update balance amount: %w", err) + return fmt.Errorf("failed to get referral count: %w", err) } - err = CountriesQ(r).FilterByCodes(*balance.Country).Update(map[string]any{ - data.ColReserved: pg.AddToValue(data.ColReserved, reward), - }) - if err != nil { - return fmt.Errorf("increase country reserve: %w", err) + refToAdd := prepareReferralsToAdd(balance.Nullifier, uint64(refsCount), count) + if err = referralsQ.New().Insert(refToAdd...); err != nil { + return fmt.Errorf("failed to insert referrals: %w", err) } return nil + } + + err = balancesQ.FilterByNullifier(balance.Nullifier).Update(map[string]any{ + data.ColAmount: pg.AddToValue(data.ColAmount, reward), + data.ColLevel: level, }) + if err != nil { + return fmt.Errorf("update balance amount and level: %w", err) + } - return claimed, nil + err = countriesQ.FilterByCodes(*balance.Country).Update(map[string]any{ + data.ColReserved: pg.AddToValue(data.ColReserved, reward), + }) + if err != nil { + return fmt.Errorf("increase country reserve: %w", err) + } + + return nil } func newClaimEventResponse( diff --git a/internal/service/handlers/verify_passport.go b/internal/service/handlers/verify_passport.go index 810aed3..350789a 100644 --- a/internal/service/handlers/verify_passport.go +++ b/internal/service/handlers/verify_passport.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "math" "math/big" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/rarimo/rarime-points-svc/internal/data" "github.com/rarimo/rarime-points-svc/internal/data/evtypes" "github.com/rarimo/rarime-points-svc/internal/service/requests" + "github.com/rarimo/rarime-points-svc/resources" zk "github.com/rarimo/zkverifier-kit" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" @@ -49,7 +51,21 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusNoContent) + event, err := EventsQ(r).FilterByNullifier(balance.Nullifier). + FilterByType(evtypes.TypePassportScan). + FilterByStatus(data.EventClaimed).Get() + if err != nil { + Log(r).WithError(err).Error("Failed to get claimed event") + ape.RenderErr(w, problems.InternalError()) + return + } + + var res resources.PassportEventStateResponse + res.Data.ID = req.Data.ID + res.Data.Type = resources.PASSPORT_EVENT_STATE + res.Data.Attributes.Claimed = (event != nil) + + ape.Render(w, res) } // getAndVerifyBalanceEligibility provides shared logic to verify that the user @@ -116,11 +132,52 @@ func doPassportScanUpdates(r *http.Request, balance data.Balance, proof zkptypes Log(r).Infof("User %s scanned passport which country has restrictions: %+v", balance.Nullifier, country) } - if err = fulfillPassportScanEvent(r, balance); err != nil { + // because for claim event must be country code + balance.Country = &country.Code + + // Fulfill passport scan event for user if event active + // Event can be automaticaly claimed if: + // 1. Autoclaim enabled for passport scan event + // 2. Reservation is allowed to the country + // 3. The country reservation limit has not been reached + if err = fulfillOrClaimPassportScanEvent(r, balance, *country); err != nil { return fmt.Errorf("fulfill passport scan event: %w", err) } - if err = addEventForReferrer(r, balance); err != nil { + // Type not filtered as inactive because expired events can be claimed + evTypeRef := EventTypes(r).Get(evtypes.TypeReferralSpecific) + if evTypeRef == nil { + Log(r).Debug("Referral specific event type is inactive") + return nil + } + if evTypeRef.Disabled { + Log(r).Infof("Event type %s is disabled", evtypes.TypeReferralSpecific) + return nil + } + + // Claim events for invited friends who scanned the passport. + // This is possible when the user registered in the referral + // program and invited friends, the friends scanned the passport, + // but since the user had an unscanned passport, the event + // could not automatically claimed to him. And now that + // user has scanned the passport, it is necessary to claim events + // for user's friends, if possible, that is, the following conditions are met: + // 1. Autoclaim enabled for passport scan event + // 2. Reservation is allowed to the country + // 3. The country reservation limit has not been reached + if err = claimReferralSpecificEvents(r, evTypeRef, balance.Nullifier); err != nil { + return fmt.Errorf("failed to claim referral specific events: %w", err) + } + + if evtypes.FilterInactive(*evTypeRef) { + Log(r).Debug("Referral specific event type is inactive: event not added") + return nil + } + + // Adds a friend event for the referrer. If the event + // is inactive, then nothing happens. If active, the + // fulfilled event is added and, if possible, the event claimed + if err = addEventForReferrer(r, evTypeRef, balance); err != nil { return fmt.Errorf("add event for referrer: %w", err) } @@ -151,7 +208,7 @@ func updateBalanceCountry(r *http.Request, balance data.Balance, proof zkptypes. return country, nil } -func fulfillPassportScanEvent(r *http.Request, balance data.Balance) error { +func fulfillOrClaimPassportScanEvent(r *http.Request, balance data.Balance, country data.Country) error { evTypePassport := EventTypes(r).Get(evtypes.TypePassportScan, evtypes.FilterInactive) if evTypePassport == nil { Log(r).Debug("Passport scan event type is inactive") @@ -169,17 +226,110 @@ func fulfillPassportScanEvent(r *http.Request, balance data.Balance) error { return errors.New("inconsistent state: balance has no country, event type is active, but no open event was found") } - _, err = EventsQ(r). - FilterByID(event.ID). - Update(data.EventFulfilled, nil, nil) + if !evTypePassport.AutoClaim || !country.ReserveAllowed || country.Reserved >= country.ReserveLimit { + _, err = EventsQ(r). + FilterByID(event.ID). + Update(data.EventFulfilled, nil, nil) + if err != nil { + return fmt.Errorf("failed to update event: %w", err) + } + + return nil + } + + _, err = EventsQ(r).FilterByID(event.ID).Update(data.EventClaimed, nil, &evTypePassport.Reward) + if err != nil { + return fmt.Errorf("update event status: %w", err) + } + + err = DoClaimEventUpdates( + Levels(r), + ReferralsQ(r), + BalancesQ(r), + CountriesQ(r), + balance, + evTypePassport.Reward) + if err != nil { + return fmt.Errorf("failed to do claim event updates for passport scan: %w", err) + } - return err + return nil } -func addEventForReferrer(r *http.Request, balance data.Balance) error { - evTypeRef := EventTypes(r).Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) +// evTypeRef must not be nil +func claimReferralSpecificEvents(r *http.Request, evTypeRef *evtypes.EventConfig, nullifier string) error { + if !evTypeRef.AutoClaim { + Log(r).Debugf("auto claim for referral specific disabled") + return nil + } + + // balance can't be nil because of previous logic + balance, err := BalancesQ(r).FilterByNullifier(nullifier).FilterDisabled().Get() + if err != nil { + return fmt.Errorf("failed to get balance: %w", err) + } + + // country can't be nill because of previous logic + country, err := CountriesQ(r).FilterByCodes(*balance.Country).Get() + if err != nil { + return fmt.Errorf("failed to get referrer country: %w", err) + } + + // if user country have restrictions for claim points then not claim events and return + if !country.ReserveAllowed || country.Reserved >= country.ReserveLimit { + Log(r).Debug("Country disallowed for reserve or limit was reached after passport scan") + return nil + } + + events, err := EventsQ(r).FilterByNullifier(balance.Nullifier). + FilterByType(evtypes.TypeReferralSpecific). + FilterByStatus(data.EventFulfilled).Select() + if err != nil { + return fmt.Errorf("get fulfilled referral specific events: %w", err) + } + + // Specify how many events can be claimed + countToClaim := int64(len(events)) + if countToClaim == 0 { + return nil + } + + // If, for example, 10 points are awarded for an event, + // and 2 points remain before reaching the reservation + // limit, then this event can be claimed. And since there + // can be many events with invited friends, need to calculate + // the maximum number of events that can be claimed in order + // not to exceed the limit. + if country.Reserved+countToClaim*evTypeRef.Reward >= country.ReserveLimit+evTypeRef.Reward { + countToClaim = int64(math.Ceil(float64(country.ReserveLimit-country.Reserved) / float64(evTypeRef.Reward))) + } + + eventsToClaimed := make([]string, countToClaim) + for i := 0; i < int(countToClaim); i++ { + eventsToClaimed[i] = events[i].ID + } + + _, err = EventsQ(r).FilterByID(eventsToClaimed...).Update(data.EventClaimed, nil, &evTypeRef.Reward) + if err != nil { + return fmt.Errorf("update event status: %w", err) + } + + err = DoClaimEventUpdates( + Levels(r), + ReferralsQ(r), + BalancesQ(r), + CountriesQ(r), + *balance, + countToClaim*evTypeRef.Reward) + if err != nil { + return fmt.Errorf("failed to do claim event updates for referral specific events: %w", err) + } + + return nil +} + +func addEventForReferrer(r *http.Request, evTypeRef *evtypes.EventConfig, balance data.Balance) error { if evTypeRef == nil { - Log(r).Debug("Referral event type is inactive, not fulfilling event for referrer") return nil } @@ -188,13 +338,84 @@ func addEventForReferrer(r *http.Request, balance data.Balance) error { if err != nil { return fmt.Errorf("get referral by ID: %w", err) } + if referral == nil { + return fmt.Errorf("critical: referred_by not null, but row in referrals absent") + } - return EventsQ(r).Insert(data.Event{ - Nullifier: referral.Nullifier, - Type: evTypeRef.Name, - Status: data.EventFulfilled, - Meta: data.Jsonb(fmt.Sprintf(`{"nullifier": "%s"}`, balance.Nullifier)), + if !evTypeRef.AutoClaim { + err = EventsQ(r).Insert(data.Event{ + Nullifier: referral.Nullifier, + Type: evTypeRef.Name, + Status: data.EventFulfilled, + Meta: data.Jsonb(fmt.Sprintf(`{"nullifier": "%s"}`, balance.Nullifier)), + }) + if err != nil { + return fmt.Errorf("failed to insert fulfilled event for referrer: %w", err) + } + + return nil + } + + referrerBalance, err := BalancesQ(r).FilterByNullifier(referral.Nullifier).Get() + if err != nil { + return fmt.Errorf("failed to get referrer balance: %w", err) + } + if referrerBalance == nil { + return fmt.Errorf("critical: referrer balance not exist [%s], while referral code exist", referral.Nullifier) + } + + if !referrerBalance.ReferredBy.Valid || referrerBalance.Country == nil { + Log(r).Debug("Referrer is genesis balance or not scanned passport") + return nil + } + + country, err := CountriesQ(r).FilterByCodes(*referrerBalance.Country).Get() + if err != nil { + return fmt.Errorf("failed to get referrer country: %w", err) + } + if country == nil { + return fmt.Errorf("critical: country must be present in database") + } + + if !country.ReserveAllowed || country.Reserved >= country.ReserveLimit { + Log(r).Debug("Referrer country have ReserveAllowed false or limit reached") + + err = EventsQ(r).Insert(data.Event{ + Nullifier: referral.Nullifier, + Type: evTypeRef.Name, + Status: data.EventFulfilled, + Meta: data.Jsonb(fmt.Sprintf(`{"nullifier": "%s"}`, balance.Nullifier)), + }) + if err != nil { + return fmt.Errorf("failed to insert fulfilled event for referrer: %w", err) + } + + return nil + } + + err = EventsQ(r).Insert(data.Event{ + Nullifier: referral.Nullifier, + Type: evTypeRef.Name, + Status: data.EventClaimed, + PointsAmount: &evTypeRef.Reward, + Meta: data.Jsonb(fmt.Sprintf(`{"nullifier": "%s"}`, balance.Nullifier)), }) + if err != nil { + return fmt.Errorf("failed to insert claimed event for referrer: %w", err) + } + + err = DoClaimEventUpdates( + Levels(r), + ReferralsQ(r), + BalancesQ(r), + CountriesQ(r), + *referrerBalance, + evTypeRef.Reward) + if err != nil { + return fmt.Errorf("failed to do claim event updates for referrer referral specific events: %w", err) + } + + return nil } func getOrCreateCountry(q data.CountriesQ, proof zkptypes.ZKProof) (*data.Country, error) { diff --git a/internal/service/workers/nooneisforgotten/main.go b/internal/service/workers/nooneisforgotten/main.go index 4bfe1d8..6f23c00 100644 --- a/internal/service/workers/nooneisforgotten/main.go +++ b/internal/service/workers/nooneisforgotten/main.go @@ -2,60 +2,190 @@ package nooneisforgotten import ( "fmt" + "math" + "sort" "github.com/rarimo/rarime-points-svc/internal/config" "github.com/rarimo/rarime-points-svc/internal/data" "github.com/rarimo/rarime-points-svc/internal/data/evtypes" "github.com/rarimo/rarime-points-svc/internal/data/pg" + "github.com/rarimo/rarime-points-svc/internal/service/handlers" "gitlab.com/distributed_lab/kit/pgdb" ) func Run(cfg config.Config, sig chan struct{}) { db := cfg.DB().Clone() + if err := pg.NewEvents(db).Transaction(func() error { + return updatePassportScanEvents(db, cfg.EventTypes(), cfg.Levels()) + }); err != nil { + panic(fmt.Errorf("failed to update passport scan events: %w", err)) + } - evType := cfg.EventTypes().Get(evtypes.TypePassportScan, evtypes.FilterInactive) - if evType != nil { - if err := updatePassportScanEvents(db); err != nil { - panic(err) - } + if err := pg.NewEvents(db).Transaction(func() error { + return updateReferralUserEvents(db, cfg.EventTypes()) + }); err != nil { + panic(fmt.Errorf("failed to update referral user events")) } - evType = cfg.EventTypes().Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) - if evType != nil { - if err := updateReferralUserEvents(db); err != nil { - panic(err) - } + if err := pg.NewEvents(db).Transaction(func() error { + return claimReferralSpecificEvents(db, cfg.EventTypes(), cfg.Levels()) + }); err != nil { + panic(fmt.Errorf("failed to claim referral specific events")) } + sig <- struct{}{} } -func updatePassportScanEvents(db *pgdb.DB) error { +// updatePassportScanEvents is needed so that if the passport +// scan events were not fulfilled or claimed because the event was disabled, +// expired or no autoclaimed, fulfill and, if possible, claim them. +// First, there is an attempt to claim as many events as +// possible and to fulfill the rest of the events. +// +// Events may not be fulfilled if the event is not active. +// Event may not be claimed if AutoClaim is disabled. +// If the event is not active, only fulfilled events will be claimed. +func updatePassportScanEvents(db *pgdb.DB, types evtypes.Types, levels config.Levels) error { + // if event inactive - fulfilled event can be claimed + evType := types.Get(evtypes.TypePassportScan) + if evType == nil { + return nil + } + if evType.Disabled { + return nil + } + + if !evType.AutoClaim && evtypes.FilterInactive(*evType) { + return nil + } + balances, err := pg.NewBalances(db).WithoutPassportEvent() if err != nil { return fmt.Errorf("failed to select balances without points for passport scan: %w", err) } - toUpdate := make([]string, 0, len(balances)) + toFulfill := make([]string, 0, len(balances)) + countriesBalancesMap := make(map[string][]data.WithoutPassportEventBalance, len(balances)) + countriesList := make([]string, 0, len(balances)) for _, balance := range balances { - if balance.EventID != nil { - toUpdate = append(toUpdate, *balance.EventID) + if balance.EventStatus == data.EventOpen { + toFulfill = append(toFulfill, balance.EventID) + } + + // country must exist because of db query logic + if _, ok := countriesBalancesMap[*balance.Country]; !ok { + countriesList = append(countriesList, *balance.Country) + countriesBalancesMap[*balance.Country] = make([]data.WithoutPassportEventBalance, 0, len(balances)) + } + countriesBalancesMap[*balance.Country] = append(countriesBalancesMap[*balance.Country], balance) + } + + // if autoclaim disabled, then event definitely active - fulfill passport scan events + if !evType.AutoClaim { + if len(toFulfill) != 0 { + _, err = pg.NewEvents(db). + FilterByID(toFulfill...). + Update(data.EventFulfilled, nil, nil) + if err != nil { + return fmt.Errorf("failed to update passport scan events: %w", err) + } + } + + return nil + } + + countries, err := pg.NewCountries(db).FilterByCodes(countriesList...).Select() + if err != nil { + return fmt.Errorf("failed to select countries: %w", err) + } + + // we need sort, because firstly claim already fulfilled event + // and then open events + for _, country := range countries { + if !country.ReserveAllowed || country.Reserved >= country.ReserveLimit { continue } + + sort.SliceStable(countriesBalancesMap[country.Code], func(i, j int) bool { + if countriesBalancesMap[country.Code][i].EventStatus == countriesBalancesMap[country.Code][j].EventStatus { + return false + } + if countriesBalancesMap[country.Code][i].EventStatus == data.EventOpen { + return true + } + return false + }) + + // Not all events can be claimed, because limit can be reached in half path + countToClaim := int(math.Min( + float64(len(countriesBalancesMap[country.Code])), + math.Ceil(float64(country.ReserveLimit-country.Reserved)/float64(evType.Reward)))) + + for i := 0; i < countToClaim; i++ { + // if event is inactive we claim only fulfilled events + if countriesBalancesMap[country.Code][i].EventStatus == data.EventOpen && evtypes.FilterInactive(*evType) { + break + } + + eventID := countriesBalancesMap[country.Code][i].EventID + _, err = pg.NewEvents(db).FilterByID(eventID).Update(data.EventClaimed, nil, &evType.Reward) + if err != nil { + return fmt.Errorf("update event status: %w", err) + } + + err = handlers.DoClaimEventUpdates( + levels, + pg.NewReferrals(db), + pg.NewBalances(db), + pg.NewCountries(db), + countriesBalancesMap[country.Code][i].Balance, + evType.Reward) + if err != nil { + return fmt.Errorf("failed to do claim event updates for passport scan: %w", err) + } + + // we mark claimed events to fulfill event which can't be claimed because of country limitations + countriesBalancesMap[country.Code][i].EventStatus = data.EventClaimed + } + } + + if evtypes.FilterInactive(*evType) { + return nil } - if len(toUpdate) != 0 { - _, err = pg.NewEvents(db). - FilterByID(toUpdate...). - Update(data.EventFulfilled, nil, nil) - if err != nil { - return fmt.Errorf("failed to update passport scan events: %w", err) + toFulfill = make([]string, 0, len(balances)) + for _, balances := range countriesBalancesMap { + for _, balance := range balances { + if balance.EventStatus == data.EventOpen { + toFulfill = append(toFulfill, balance.EventID) + } } } + if len(toFulfill) == 0 { + return nil + } + + _, err = pg.NewEvents(db). + FilterByID(toFulfill...). + Update(data.EventFulfilled, nil, nil) + if err != nil { + return fmt.Errorf("failed to update passport scan events: %w", err) + } + return nil } -func updateReferralUserEvents(db *pgdb.DB) error { +// updateReferralUserEvents is used to add events for referrers +// for friends who have scanned the passport, if they have not been added. +// +// Events are not added if the event is inactive or disabled +func updateReferralUserEvents(db *pgdb.DB, types evtypes.Types) error { + evTypeRef := types.Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) + if evTypeRef == nil { + return nil + } + refPairs, err := pg.NewBalances(db).WithoutReferralEvent() if err != nil { return fmt.Errorf("failed to select balances without points for referred users: %w", err) @@ -71,11 +201,120 @@ func updateReferralUserEvents(db *pgdb.DB) error { }) } - if len(toInsert) != 0 { - err = pg.NewEvents(db).Insert(toInsert...) - if err != nil { - return fmt.Errorf("failed to insert referred user events: %w", err) + if len(toInsert) == 0 { + return nil + } + + if err = pg.NewEvents(db).Insert(toInsert...); err != nil { + return fmt.Errorf("failed to insert referred user events: %w", err) + } + + return nil +} + +// claimReferralSpecificEvents claim fulfilled events for invited +// friends which have passport scanned, if it possible +func claimReferralSpecificEvents(db *pgdb.DB, types evtypes.Types, levels config.Levels) error { + evType := types.Get(evtypes.TypeReferralSpecific) + if evType == nil { + return nil + } + if evType.Disabled { + return nil + } + if !evType.AutoClaim { + return nil + } + + events, err := pg.NewEvents(db).FilterByType(evtypes.TypeReferralSpecific).FilterByStatus(data.EventFulfilled).Select() + if err != nil { + return fmt.Errorf("failed to select passport scan events: %w", err) + } + + if len(events) == 0 { + return nil + } + + // we need to have maps which link nullifiers to events slice and countries to balances slice + nullifiersEventsMap := make(map[string][]data.Event, len(events)) + nullifiers := make([]string, 0, len(events)) + for _, event := range events { + if _, ok := nullifiersEventsMap[event.Nullifier]; !ok { + nullifiersEventsMap[event.Nullifier] = make([]data.Event, 0, len(events)) + nullifiers = append(nullifiers, event.Nullifier) } + nullifiersEventsMap[event.Nullifier] = append(nullifiersEventsMap[event.Nullifier], event) + } + + balances, err := pg.NewBalances(db).FilterByNullifier(nullifiers...).FilterDisabled().Select() + if err != nil { + return fmt.Errorf("failed to select balances for claim passport scan event: %w", err) + } + if len(balances) == 0 { + return fmt.Errorf("critical: events present, but no balances with nullifier") + } + + countriesBalancesMap := make(map[string][]data.Balance, len(balances)) + for _, balance := range balances { + // country can't be nil because of db query logic + if _, ok := countriesBalancesMap[*balance.Country]; !ok { + countriesBalancesMap[*balance.Country] = make([]data.Balance, 0, len(balances)) + } + + countriesBalancesMap[*balance.Country] = append(countriesBalancesMap[*balance.Country], balance) + } + + countryCodes := make([]string, 0, len(countriesBalancesMap)) + for k := range countriesBalancesMap { + countryCodes = append(countryCodes, k) + } + + countries, err := pg.NewCountries(db).FilterByCodes(countryCodes...).Select() + if err != nil { + return fmt.Errorf("failed to select countries for claim passport scan events: %w", err) + } + + // toClaim - event ids which must be claimed + toClaim := make([]string, 0, len(events)) + for _, country := range countries { + // if country have limitations - skip this + if !country.ReserveAllowed || country.Reserved >= country.ReserveLimit { + continue + } + + limit := country.ReserveLimit - country.Reserved + for _, balance := range countriesBalancesMap[country.Code] { + // if limit reached we need stop + if limit <= 0 { + break + } + + toAccrue := int64(0) + for _, event := range nullifiersEventsMap[balance.Nullifier] { + limit -= evType.Reward + toClaim = append(toClaim, event.ID) + toAccrue += evType.Reward + if limit <= 0 { + break + } + } + + err = handlers.DoClaimEventUpdates( + levels, + pg.NewReferrals(db), + pg.NewBalances(db), + pg.NewCountries(db), + balance, + evType.Reward) + if err != nil { + return fmt.Errorf("failed to do claim event updates for referral specific event: %w", err) + } + } + } + + _, err = pg.NewEvents(db).FilterByID(toClaim...).Update(data.EventClaimed, nil, &evType.Reward) + if err != nil { + return fmt.Errorf("update event status: %w", err) } return nil diff --git a/resources/model_passport_event_state.go b/resources/model_passport_event_state.go new file mode 100644 index 0000000..79dd9f4 --- /dev/null +++ b/resources/model_passport_event_state.go @@ -0,0 +1,43 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +import "encoding/json" + +type PassportEventState struct { + Key + Attributes PassportEventStateAttributes `json:"attributes"` +} +type PassportEventStateResponse struct { + Data PassportEventState `json:"data"` + Included Included `json:"included"` +} + +type PassportEventStateListResponse struct { + Data []PassportEventState `json:"data"` + Included Included `json:"included"` + Links *Links `json:"links"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +func (r *PassportEventStateListResponse) PutMeta(v interface{}) (err error) { + r.Meta, err = json.Marshal(v) + return err +} + +func (r *PassportEventStateListResponse) GetMeta(out interface{}) error { + return json.Unmarshal(r.Meta, out) +} + +// MustPassportEventState - returns PassportEventState from include collection. +// if entry with specified key does not exist - returns nil +// if entry with specified key exists but type or ID mismatches - panics +func (c *Included) MustPassportEventState(key Key) *PassportEventState { + var passportEventState PassportEventState + if c.tryFindEntry(key, &passportEventState) { + return &passportEventState + } + return nil +} diff --git a/resources/model_passport_event_state_attributes.go b/resources/model_passport_event_state_attributes.go new file mode 100644 index 0000000..1d3107c --- /dev/null +++ b/resources/model_passport_event_state_attributes.go @@ -0,0 +1,10 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +type PassportEventStateAttributes struct { + // If passport scan event was automatically claimed + Claimed bool `json:"claimed"` +} diff --git a/resources/model_resource_type.go b/resources/model_resource_type.go index 7e78005..4bdb03c 100644 --- a/resources/model_resource_type.go +++ b/resources/model_resource_type.go @@ -8,14 +8,15 @@ type ResourceType string // List of ResourceType const ( - BALANCE ResourceType = "balance" - CLAIM_EVENT ResourceType = "claim_event" - COUNTRIES_CONFIG ResourceType = "countries_config" - CREATE_BALANCE ResourceType = "create_balance" - UPDATE_BALANCE ResourceType = "update_balance" - EVENT ResourceType = "event" - POINT_PRICE ResourceType = "point_price" - VERIFY_PASSPORT ResourceType = "verify_passport" - WITHDRAW ResourceType = "withdraw" - WITHDRAWAL ResourceType = "withdrawal" + BALANCE ResourceType = "balance" + CLAIM_EVENT ResourceType = "claim_event" + COUNTRIES_CONFIG ResourceType = "countries_config" + CREATE_BALANCE ResourceType = "create_balance" + UPDATE_BALANCE ResourceType = "update_balance" + EVENT ResourceType = "event" + PASSPORT_EVENT_STATE ResourceType = "passport_event_state" + POINT_PRICE ResourceType = "point_price" + VERIFY_PASSPORT ResourceType = "verify_passport" + WITHDRAW ResourceType = "withdraw" + WITHDRAWAL ResourceType = "withdrawal" )