diff --git a/README.md b/README.md index a4ed8e2..2948385 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,34 @@ functionality to interact with these endpoints. The path for internal endpoints is `/integrations/rarime-points-svc/v1/private/*`. +### Add referrals + +Private endpoint to set number of available referral codes or create a new +_System user_ with referrals. _System user_ is unable to claim events or +withdraw, it has `is_disabled` attribute set to `true`, so the client app should +not allow it interactions with the system, although it is technically possible +to do other actions. + +Path: `/integrations/rarime-points-svc/v1/private/referrals` +Query parameters: +- `did` - user DID to create or edit referrals for +- `count` - number of referrals to set + +Example to set 200 referrals for user `did:example:123`: +```shell +curl -X POST "http://localhost/integrations/rarime-points-svc/v1/private/referrals?did=did:example:123&count=200" +``` + +Behavior: +a) User does not exist -> create a _System user_ with the specified number of +referrals (if count == 0, do not create) +b) User exists, `N` > `count` -> add `N - count` active referrals +c) User exists, `N` < `count` -> consume `count - N` active referrals (not delete!) +d) User exists, `N` = `count` -> do nothing + +Where `N` is the current number of active referrals for the user, `count` is +query parameter value. + ### Local build We do use openapi:json standard for API. We use swagger for documenting our API. diff --git a/config.yaml b/config.yaml index 4089c35..b357b7b 100644 --- a/config.yaml +++ b/config.yaml @@ -17,11 +17,15 @@ event_types: title: Points for passport scan reward: 200 description: Get points for scan passport and share data + short_description: Short description frequency: one-time + action_url: https://... + logo: https://... - name: get_poh title: Get PoH credential reward: 50 description: Prove that you are human + short_description: Short description frequency: one-time expires_at: 2020-01-01T00:00:00Z - name: free_weekly @@ -29,29 +33,77 @@ event_types: reward: 100 frequency: weekly description: Get free points every week by visiting the platform and claiming your reward + short_description: Short description - name: daily_login title: Daily login reward: 5 frequency: daily description: Login every day + short_description: Short description disabled: true - name: be_referred title: Referral welcome bonus reward: 5 frequency: one-time description: Be referred by a friend and get a welcome bonus + short_description: Short description no_auto_open: true - name: referral_common title: Refer new users reward: 25 frequency: one-time description: Refer friends and get a reward for each friend who verifies the passport + short_description: Short description - name: referral_specific title: Refer user {:did} reward: 25 frequency: unlimited description: The user {:did} has verified the passport. Claim the reward! + short_description: Short description no_auto_open: true + - name: planned + title: Planned event + reward: 25 + frequency: unlimited + description: Event that start at specified time + short_description: Short description + starts_at: 2020-01-01T00:00:00Z + - name: generate_proof_age + title: Generate proof age + reward: 25 + frequency: one-time + description: Event that become fulfilled when user create proof that prove age + short_description: Short description + - name: generate_proof_nationality + title: Generate proof nationality + reward: 50 + frequency: one-time + description: Event that become fulfilled when user create proof that prove nationality + short_description: Short description + - name: verify_proof_age + title: Verify proof age + reward: 25 + frequency: one-time + description: Event that become fulfilled when user verify someone else's proof age + short_description: Short description + - name: verify_proof_nationality + title: Verify proof nationality + reward: 50 + frequency: one-time + description: Event that become fulfilled when user verify someone else's proof nationality + short_description: Short description + - name: verified_proof_age + title: Have proof age verified + reward: 25 + frequency: one-time + description: Event that become fulfilled when another user verify you proof age (user that verify must have verified passport) + short_description: Short description + - name: verified_proof_nationality + title: Have proof nationality verified + reward: 50 + frequency: one-time + description: Event that become fulfilled when another user verify you proof nationality (user that verify must have verified passport) + short_description: Short description auth: addr: http://rarime-auth diff --git a/docs/spec/components/schemas/Balance.yaml b/docs/spec/components/schemas/Balance.yaml index f08f9ef..a703b45 100644 --- a/docs/spec/components/schemas/Balance.yaml +++ b/docs/spec/components/schemas/Balance.yaml @@ -42,9 +42,21 @@ allOf: format: int description: Rank of the user in the full leaderboard. Returned only for the single user. example: 294 - referral_codes: + active_referral_codes: type: array - description: Referral codes used to build a referral link and send it to friends. Required if a balance is created + description: | + Referral codes which can be used to build a referral link and send it + to friends. Returned only for the single user. example: ["zgsScguZ", "jerUsmac"] items: type: string + consumed_referral_codes: + type: array + description: Referral codes used by invited users. Returned only for the single user. + example: ["73k3bdYaFWM", "9csIL7dW65m"] + items: + type: string + is_withdrawal_allowed: + type: boolean + description: Whether the user can withdraw tokens. Returned only for the single user. + example: true diff --git a/docs/spec/components/schemas/CreateBalanceKey.yaml b/docs/spec/components/schemas/CreateBalanceKey.yaml index ca44323..9707e16 100644 --- a/docs/spec/components/schemas/CreateBalanceKey.yaml +++ b/docs/spec/components/schemas/CreateBalanceKey.yaml @@ -9,4 +9,4 @@ properties: example: "did:iden3:readonly:tUDjWxnVJNi7t3FudukqrUcNwF5KVGoWgim5pp2jV" type: type: string - enum: [ create_balance ] + enum: [ create_balance, update_balance ] diff --git a/docs/spec/components/schemas/EventStaticMeta.yaml b/docs/spec/components/schemas/EventStaticMeta.yaml index f699917..3652d1e 100644 --- a/docs/spec/components/schemas/EventStaticMeta.yaml +++ b/docs/spec/components/schemas/EventStaticMeta.yaml @@ -7,6 +7,7 @@ required: - reward - title - description + - short_description - frequency - no_auto_open properties: @@ -25,14 +26,30 @@ properties: description: type: string example: Lorem ipsum dolor sit amet + short_description: + type: string + example: Short description frequency: type: string description: | Event frequency, which means how often you can fulfill certain task and claim the reward. enum: [one-time, daily, weekly, unlimited] + starts_at: + type: string + format: time.Time + description: General event starting date (UTC RFC3339) + example: 2020-01-01T00:00:00Z expires_at: type: string format: time.Time description: General event expiration date (UTC RFC3339) example: 2020-01-01T00:00:00Z + action_url: + type: string + description: Page where you can fulfill the event + example: https://robotornot.rarimo.com + logo: + type: string + description: Event logo + example: https://logo.com/some_logo.svg diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances.yaml index cdc8462..a618e20 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances.yaml @@ -10,7 +10,7 @@ post: the new account synchronously (to display them right after the request). If balance already exists, but it is disabled (it was not referred by another user, - but has fulfilled some event), you should use this endpoint as well. + but has fulfilled some event), you should use PATCH balances/{did} endpoint as well. operationId: createPointsBalance requestBody: content: diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{did}.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{did}.yaml index dcabba5..4cf57e5 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{did}.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@balances@{did}.yaml @@ -40,3 +40,51 @@ get: $ref: '#/components/responses/invalidAuth' 500: $ref: '#/components/responses/internalError' + +patch: + tags: + - Points balance + summary: Activate points balance + description: | + Activate inactive balance. Balance is inactive when referred_by field is null. + did in query must match with id in request body + operationId: activatePointsBalance + parameters: + - $ref: '#/components/parameters/pathDID' + requestBody: + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/CreateBalance' + + responses: + 200: + description: Success + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Balance' + 400: + $ref: '#/components/responses/invalidParameter' + 401: + $ref: '#/components/responses/invalidAuth' + 404: + $ref: '#/components/responses/notFound' + 409: + description: Balance already activated + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/Errors' + 500: + $ref: '#/components/responses/internalError' diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@events.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@events.yaml index 0f756fe..c1668e4 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@events.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@events.yaml @@ -35,8 +35,8 @@ get: schema: type: boolean example: true - - $ref: '#/components/parameters/pageCursor' - $ref: '#/components/parameters/pageLimit' + - $ref: '#/components/parameters/pageNumber' - $ref: '#/components/parameters/pageOrder' responses: 200: diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@public@events@{id}.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@public@events@{id}.yaml index a65de9f..0756514 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@public@events@{id}.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@public@events@{id}.yaml @@ -1,3 +1,30 @@ +get: + tags: + - Events + summary: Get event + description: Returns a single event by ID. + operationId: getEvent + responses: + 200: + description: Success + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/Event' + 400: + $ref: '#/components/responses/invalidParameter' + 401: + $ref: '#/components/responses/invalidAuth' + 404: + $ref: '#/components/responses/notFound' + 500: + $ref: '#/components/responses/internalError' + patch: tags: - Events diff --git a/internal/assets/migrations/001_initial.sql b/internal/assets/migrations/001_initial.sql index 72ce780..445e810 100644 --- a/internal/assets/migrations/001_initial.sql +++ b/internal/assets/migrations/001_initial.sql @@ -5,13 +5,14 @@ AS $$ BEGIN NEW.updated_at = EXTRACT('EPOCH' FROM NOW()); RETURN NEW; END; $$; CREATE TABLE IF NOT EXISTS balances ( - did text PRIMARY KEY, - amount bigint NOT NULL default 0, - created_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), - updated_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), - referred_by text UNIQUE, - passport_hash text UNIQUE, - passport_expires timestamp without time zone + did text PRIMARY KEY, + amount bigint NOT NULL default 0, + created_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), + updated_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), + referred_by text UNIQUE, + passport_hash text UNIQUE, + passport_expires timestamp without time zone, + is_withdrawal_allowed boolean NOT NULL default false ); CREATE INDEX IF NOT EXISTS balances_page_index ON balances (amount, updated_at) WHERE referred_by IS NOT NULL; diff --git a/internal/data/balances.go b/internal/data/balances.go index 14ff88c..97f4284 100644 --- a/internal/data/balances.go +++ b/internal/data/balances.go @@ -8,21 +8,22 @@ import ( ) type Balance struct { - DID string `db:"did"` - Amount int64 `db:"amount"` - CreatedAt int32 `db:"created_at"` - UpdatedAt int32 `db:"updated_at"` - ReferredBy sql.NullString `db:"referred_by"` - PassportHash sql.NullString `db:"passport_hash"` - PassportExpires sql.NullTime `db:"passport_expires"` - Rank *int `db:"rank"` + DID string `db:"did"` + Amount int64 `db:"amount"` + CreatedAt int32 `db:"created_at"` + UpdatedAt int32 `db:"updated_at"` + ReferredBy sql.NullString `db:"referred_by"` + PassportHash sql.NullString `db:"passport_hash"` + PassportExpires sql.NullTime `db:"passport_expires"` + Rank *int `db:"rank"` + IsWithdrawalAllowed bool `db:"is_withdrawal_allowed"` } type BalancesQ interface { New() BalancesQ Insert(Balance) error UpdateAmountBy(points int64) error - SetPassport(hash string, exp time.Time) error + SetPassport(hash string, exp time.Time, isWithdrawalAllowed bool) error SetReferredBy(referralCode string) error Page(*pgdb.OffsetPageParams) BalancesQ @@ -32,5 +33,6 @@ type BalancesQ interface { GetWithRank(did string) (*Balance, error) FilterByDID(string) BalancesQ + FilterByPassportHash(string) BalancesQ FilterDisabled() BalancesQ } diff --git a/internal/data/events.go b/internal/data/events.go index 9663b66..c1e51a2 100644 --- a/internal/data/events.go +++ b/internal/data/events.go @@ -44,7 +44,7 @@ type EventsQ interface { Delete() (rowsAffected int64, err error) Transaction(func() error) error - Page(*pgdb.CursorPageParams) EventsQ + Page(*pgdb.OffsetPageParams) EventsQ Select() ([]Event, error) Get() (*Event, error) // Count returns the total number of events that match filters. Page is not diff --git a/internal/data/evtypes/config.go b/internal/data/evtypes/config.go index fb52ee7..971b14f 100644 --- a/internal/data/evtypes/config.go +++ b/internal/data/evtypes/config.go @@ -40,6 +40,12 @@ func (c *config) EventTypes() Types { if !checkFreqValue(t.Frequency) { panic(fmt.Errorf("invalid frequency: %s", t.Frequency)) } + + if t.ExpiresAt != nil && t.StartsAt != nil && !t.StartsAt.Before(*t.ExpiresAt) { + panic(fmt.Errorf("starts_at must be before expires_at: %s > %s", + t.StartsAt, t.ExpiresAt)) + } + m[t.Name] = t } diff --git a/internal/data/evtypes/filters.go b/internal/data/evtypes/filters.go index 615bbb0..6c9c823 100644 --- a/internal/data/evtypes/filters.go +++ b/internal/data/evtypes/filters.go @@ -10,8 +10,12 @@ func FilterExpired(ev EventConfig) bool { return ev.ExpiresAt != nil && ev.ExpiresAt.Before(time.Now().UTC()) } +func FilterNotStarted(ev EventConfig) bool { + return ev.StartsAt != nil && ev.StartsAt.After(time.Now().UTC()) +} + func FilterInactive(ev EventConfig) bool { - return ev.Disabled || FilterExpired(ev) + return ev.Disabled || FilterExpired(ev) || FilterNotStarted(ev) } func FilterNotOpenable(ev EventConfig) bool { diff --git a/internal/data/evtypes/main.go b/internal/data/evtypes/main.go index 846fd93..921c5b5 100644 --- a/internal/data/evtypes/main.go +++ b/internal/data/evtypes/main.go @@ -1,6 +1,7 @@ package evtypes import ( + "net/url" "time" "github.com/rarimo/rarime-points-svc/internal/data" @@ -34,24 +35,40 @@ const ( ) type EventConfig struct { - Name string `fig:"name,required"` - Description string `fig:"description,required"` - Reward int64 `fig:"reward,required"` - Title string `fig:"title,required"` - Frequency Frequency `fig:"frequency,required"` - ExpiresAt *time.Time `fig:"expires_at"` - NoAutoOpen bool `fig:"no_auto_open"` - Disabled bool `fig:"disabled"` + Name string `fig:"name,required"` + Description string `fig:"description,required"` + ShortDescription string `fig:"short_description,required"` + Reward int64 `fig:"reward,required"` + Title string `fig:"title,required"` + Frequency Frequency `fig:"frequency,required"` + StartsAt *time.Time `fig:"starts_at"` + ExpiresAt *time.Time `fig:"expires_at"` + NoAutoOpen bool `fig:"no_auto_open"` + Disabled bool `fig:"disabled"` + ActionURL *url.URL `fig:"action_url"` + Logo *url.URL `fig:"logo"` } func (e EventConfig) Resource() resources.EventStaticMeta { + safeConv := func(u *url.URL) *string { + if u == nil { + return nil + } + s := u.String() + return &s + } + return resources.EventStaticMeta{ - Name: e.Name, - Description: e.Description, - Reward: e.Reward, - Title: e.Title, - Frequency: e.Frequency.String(), - ExpiresAt: e.ExpiresAt, + Name: e.Name, + Description: e.Description, + ShortDescription: e.ShortDescription, + Reward: e.Reward, + Title: e.Title, + Frequency: e.Frequency.String(), + StartsAt: e.StartsAt, + ExpiresAt: e.ExpiresAt, + ActionUrl: safeConv(e.ActionURL), + Logo: safeConv(e.Logo), } } @@ -126,13 +143,14 @@ func (t Types) ensureInitialized() { } } -func (t Types) CalculatePassportScanReward(sharedFields ...string) (reward int64, success bool) { +func (t Types) CalculatePassportScanReward(sharedFields ...string) (*int64, bool) { + var reward int64 for _, field := range sharedFields { val, ok := t.passportRewards[field] if !ok { - return 0, false + return nil, false } reward += int64(val) } - return reward, true + return &reward, true } diff --git a/internal/data/jsonb.go b/internal/data/jsonb.go index f3759ae..e7abfd9 100644 --- a/internal/data/jsonb.go +++ b/internal/data/jsonb.go @@ -3,6 +3,7 @@ package data import ( "database/sql/driver" "encoding/json" + "fmt" "gitlab.com/distributed_lab/kit/pgdb" ) @@ -16,6 +17,31 @@ func (j *Jsonb) Value() (driver.Value, error) { return pgdb.JSONValue(j) } +func (j *Jsonb) UnmarshalJSON(data []byte) error { + if j == nil { + return fmt.Errorf("json.RawMessage: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], data...) + return nil +} + func (j *Jsonb) Scan(src interface{}) error { - return pgdb.JSONScan(src, j) + var data []byte + switch rawData := src.(type) { + case []byte: + data = rawData + case string: + data = []byte(rawData) + case nil: + data = []byte("null") + default: + return fmt.Errorf("unexpected type for jsonb: %T", src) + } + + err := json.Unmarshal(data, j) + if err != nil { + return fmt.Errorf("failed to unmarshal: %w", err) + } + + return nil } diff --git a/internal/data/pg/balances.go b/internal/data/pg/balances.go index 7ed3a55..8e13abc 100644 --- a/internal/data/pg/balances.go +++ b/internal/data/pg/balances.go @@ -58,13 +58,14 @@ func (q *balances) UpdateAmountBy(points int64) error { return nil } -func (q *balances) SetPassport(hash string, exp time.Time) error { +func (q *balances) SetPassport(hash string, exp time.Time, isWithdrawalAllowed bool) error { stmt := q.updater. Set("passport_hash", hash). - Set("passport_expires", exp) + Set("passport_expires", exp). + Set("is_withdrawal_allowed", isWithdrawalAllowed) if err := q.db.Exec(stmt); err != nil { - return fmt.Errorf("set passport hash and expires: %w", err) + return fmt.Errorf("set passport hash and expires, and isWithdrawalAllowed: %w", err) } return nil @@ -133,6 +134,10 @@ func (q *balances) FilterByDID(did string) data.BalancesQ { return q.applyCondition(squirrel.Eq{"did": did}) } +func (q *balances) FilterByPassportHash(passportHash string) data.BalancesQ { + return q.applyCondition(squirrel.Eq{"passport_hash": passportHash}) +} + func (q *balances) FilterDisabled() data.BalancesQ { return q.applyCondition(squirrel.NotEq{"referred_by": nil}) } diff --git a/internal/data/pg/events.go b/internal/data/pg/events.go index 146ea9a..171a11c 100644 --- a/internal/data/pg/events.go +++ b/internal/data/pg/events.go @@ -99,8 +99,9 @@ func (q *events) Transaction(f func() error) error { return q.db.Transaction(f) } -func (q *events) Page(page *pgdb.CursorPageParams) data.EventsQ { - q.selector = page.ApplyTo(q.selector, "updated_at") +func (q *events) Page(page *pgdb.OffsetPageParams) data.EventsQ { + ord := fmt.Sprintf("case when status = '%s' then 1 when status = '%s' then 2 when status = '%s' then 3 end", data.EventFulfilled, data.EventOpen, data.EventClaimed) + q.selector = page.ApplyTo(q.selector.OrderBy(ord), "updated_at") return q } diff --git a/internal/data/pg/referrals.go b/internal/data/pg/referrals.go index 1959a09..85ccb38 100644 --- a/internal/data/pg/referrals.go +++ b/internal/data/pg/referrals.go @@ -1,6 +1,8 @@ package pg import ( + "database/sql" + "errors" "fmt" "github.com/Masterminds/squirrel" @@ -70,7 +72,7 @@ func (q *referrals) ConsumeFirst(did string, count uint64) error { UPDATE %s SET is_consumed = true WHERE id IN ( SELECT id FROM %s WHERE user_did = ? AND is_consumed = false - ORDER BY created_at ASC LIMIT %d + LIMIT %d ); `, referralsTable, referralsTable, count) @@ -95,6 +97,9 @@ func (q *referrals) Get(id string) (*data.Referral, error) { var res data.Referral if err := q.db.Get(&res, q.selector.Where(squirrel.Eq{"id": id})); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } return nil, fmt.Errorf("get referral by ID: %w", err) } diff --git a/internal/service/handlers/activate_balance.go b/internal/service/handlers/activate_balance.go new file mode 100644 index 0000000..e8dedec --- /dev/null +++ b/internal/service/handlers/activate_balance.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/rarimo/auth-svc/pkg/auth" + "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" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +func ActivateBalance(w http.ResponseWriter, r *http.Request) { + req, err := requests.NewActivateBalance(r) + if err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + did := req.Data.ID + + if !auth.Authenticates(UserClaims(r), auth.UserGrant(did)) { + ape.RenderErr(w, problems.Unauthorized()) + return + } + + balance, err := BalancesQ(r).FilterByDID(did).Get() + if err != nil { + Log(r).WithError(err).Error("Failed to get balance by DID") + ape.RenderErr(w, problems.InternalError()) + return + } + + if balance == nil { + Log(r).Debug("Balance not exist") + ape.RenderErr(w, problems.NotFound()) + return + } + + // Balance should be inactive + if balance.ReferredBy.Valid { + Log(r).Debug("Balance already activated") + ape.RenderErr(w, problems.Conflict()) + return + } + + referral, err := ReferralsQ(r).FilterByIsConsumed(false).Get(req.Data.Attributes.ReferredBy) + if err != nil { + Log(r).WithError(err).Error("Failed to get referral by ID") + ape.RenderErr(w, problems.InternalError()) + return + } + + if referral == nil { + Log(r).Debug("Referral code already consumed or not exists") + ape.RenderErr(w, problems.NotFound()) + return + } + + referrals := prepareReferralsToAdd(did, 5, 0) + + err = EventsQ(r).Transaction(func() error { + Log(r).Debugf("%s referral code will be set for user_did=%s", req.Data.Attributes.ReferredBy, did) + if err = BalancesQ(r).FilterByDID(did).SetReferredBy(req.Data.Attributes.ReferredBy); err != nil { + return fmt.Errorf("set referred_by: %w", err) + } + + Log(r).Debugf("%d referrals will be added for user_did=%s", len(referrals), did) + if err = ReferralsQ(r).Insert(referrals...); err != nil { + return fmt.Errorf("add referrals: %w", err) + } + + Log(r).Debugf("%s referral will be consumed", req.Data.Attributes.ReferredBy) + if _, err = ReferralsQ(r).Consume(req.Data.Attributes.ReferredBy); err != nil { + return fmt.Errorf("consume referral: %w", err) + } + + if balance.PassportHash.Valid { + evType := EventTypes(r).Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) + if evType == nil { + Log(r).Debug("Referral event type is disabled or expired, not accruing points to referrer") + return nil + } + + err = EventsQ(r).Insert(data.Event{ + UserDID: referral.UserDID, + Type: evType.Name, + Status: data.EventFulfilled, + Meta: data.Jsonb(fmt.Sprintf(`{"did": "%s"}`, did)), + }) + if err != nil { + return fmt.Errorf("add event for referrer: %w", err) + } + } + return nil + }) + + if err != nil { + Log(r).WithError(err).Error("Failed to activate balance") + ape.RenderErr(w, problems.InternalError()) + return + } + + balance, err = BalancesQ(r).GetWithRank(did) + if err != nil { + Log(r).WithError(err).Error("Failed to get balance by DID with rank") + ape.RenderErr(w, problems.InternalError()) + return + } + + ape.Render(w, newBalanceResponse(*balance, referrals)) +} diff --git a/internal/service/handlers/claim_event.go b/internal/service/handlers/claim_event.go index 55c3331..1ab632d 100644 --- a/internal/service/handlers/claim_event.go +++ b/internal/service/handlers/claim_event.go @@ -6,6 +6,7 @@ import ( "github.com/rarimo/auth-svc/pkg/auth" "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" "gitlab.com/distributed_lab/ape" @@ -47,6 +48,15 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { ape.RenderErr(w, problems.Forbidden()) return } + if event.Type == evtypes.TypePassportScan { + if event.PointsAmount == nil { + Log(r).WithError(err).Errorf("PointsAmount can't be nil for event %s", + event.Type) + ape.RenderErr(w, problems.InternalError()) + return + } + evType.Reward = *event.PointsAmount + } balance, err := BalancesQ(r).FilterByDID(event.UserDID).FilterDisabled().Get() if err != nil { diff --git a/internal/service/handlers/create_balance.go b/internal/service/handlers/create_balance.go index a56704b..d1a4ce4 100644 --- a/internal/service/handlers/create_balance.go +++ b/internal/service/handlers/create_balance.go @@ -34,76 +34,39 @@ func CreateBalance(w http.ResponseWriter, r *http.Request) { return } - // Balance should not exist or be inactive - if balance != nil && balance.ReferredBy.Valid { + if balance != nil { ape.RenderErr(w, problems.Conflict()) return } referral, err := ReferralsQ(r).FilterByIsConsumed(false).Get(req.Data.Attributes.ReferredBy) - if referral == nil { - ape.RenderErr(w, problems.NotFound()) - return - } - if err != nil { Log(r).WithError(err).Error("Failed to get referral by ID") ape.RenderErr(w, problems.InternalError()) return } - referrals := prepareReferralsToAdd(did, 5, 0) - - if balance == nil { - events := prepareEventsWithRef(did, req.Data.Attributes.ReferredBy, r) - if err = createBalanceWithEventsAndReferrals(did, req.Data.Attributes.ReferredBy, events, referrals, r); err != nil { - Log(r).WithError(err).Error("Failed to create balance with events") - ape.RenderErr(w, problems.InternalError()) - return - } - - // We can't return inserted balance in a single query, because we can't calculate - // rank in transaction: RANK() is a window function allowed on a set of rows, - // while with RETURNING we operate a single one. - // Balance will exist cause of previous logic. - balance, err = BalancesQ(r).GetWithRank(did) - if err != nil { - Log(r).WithError(err).Error("Failed to get created balance by DID") - ape.RenderErr(w, problems.InternalError()) - return - } - - ape.Render(w, newBalanceResponse(*balance, referrals)) + if referral == nil { + ape.RenderErr(w, problems.NotFound()) return } - err = EventsQ(r).Transaction(func() error { - Log(r).Debugf("%s referral code will be added for user_did=%s", req.Data.Attributes.ReferredBy, did) - if err = BalancesQ(r).FilterByDID(did).SetReferredBy(req.Data.Attributes.ReferredBy); err != nil { - return fmt.Errorf("set referred_by: %w", err) - } - - Log(r).Debugf("%d referrals will be added for user_did=%s", len(referrals), did) - if err = ReferralsQ(r).Insert(referrals...); err != nil { - return fmt.Errorf("add referrals: %w", err) - } - - Log(r).Debugf("%s referral will be consumed", req.Data.Attributes.ReferredBy) - if _, err = ReferralsQ(r).Consume(req.Data.Attributes.ReferredBy); err != nil { - return fmt.Errorf("consume referral: %w", err) - } - return nil - }) + referrals := prepareReferralsToAdd(did, 5, 0) - if err != nil { - Log(r).WithError(err).Error("Failed to activate balance") + events := prepareEventsWithRef(did, req.Data.Attributes.ReferredBy, r) + if err = createBalanceWithEventsAndReferrals(did, req.Data.Attributes.ReferredBy, events, referrals, r); err != nil { + Log(r).WithError(err).Error("Failed to create balance with events") ape.RenderErr(w, problems.InternalError()) return } + // We can't return inserted balance in a single query, because we can't calculate + // rank in transaction: RANK() is a window function allowed on a set of rows, + // while with RETURNING we operate a single one. + // Balance will exist cause of previous logic. balance, err = BalancesQ(r).GetWithRank(did) if err != nil { - Log(r).WithError(err).Error("Failed to get balance by DID with rank") + Log(r).WithError(err).Error("Failed to get created balance by DID") ape.RenderErr(w, problems.InternalError()) return } diff --git a/internal/service/handlers/edit_referrals.go b/internal/service/handlers/edit_referrals.go index 3f6f882..ba9b8e0 100644 --- a/internal/service/handlers/edit_referrals.go +++ b/internal/service/handlers/edit_referrals.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "net/http" "github.com/rarimo/rarime-points-svc/internal/data" @@ -26,6 +27,7 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { if balance == nil { if *req.Count == 0 { + Log(r).Debugf("Balance %s not found, skipping creation for count=0", req.DID) w.WriteHeader(http.StatusNoContent) return } @@ -37,24 +39,16 @@ func EditReferrals(w http.ResponseWriter, r *http.Request) { } } - var index uint64 - if balance != nil { - index, err = ReferralsQ(r).FilterByUserDID(balance.DID).Count() - if err != nil { - Log(r).WithError(err).Error("Failed to get referral count for user DID") - ape.RenderErr(w, problems.InternalError()) - return - } - } - - referrals, err := adjustReferralsCount(index, req, r) + added, err := adjustReferralsCount(req, r) if err != nil { Log(r).WithError(err).Error("Failed to adjust referrals count") ape.RenderErr(w, problems.InternalError()) return } - ape.Render(w, newBalanceResponse(*balance, referrals)) + ape.Render(w, struct { + Refs []string `json:"added_referrals"` + }{added}) } func prepareReferralsToAdd(did string, count, index uint64) []data.Referral { @@ -71,26 +65,40 @@ func prepareReferralsToAdd(did string, count, index uint64) []data.Referral { return refs } -func adjustReferralsCount(index uint64, req requests.EditReferralsRequest, r *http.Request) (refs []data.Referral, err error) { - switch { - case *req.Count < index: - toConsume := index - *req.Count +func adjustReferralsCount(req requests.EditReferralsRequest, r *http.Request) (refsAdded []string, err error) { + active, err := ReferralsQ(r).FilterByUserDID(req.DID).FilterByIsConsumed(false).Count() + if err != nil { + return nil, fmt.Errorf("count active referrals: %w", err) + } + + if *req.Count == active { + Log(r).Infof("No referrals to add or consume for DID %s", req.DID) + return + } + + if *req.Count < active { + toConsume := active - *req.Count if err = ReferralsQ(r).ConsumeFirst(req.DID, toConsume); err != nil { - return + return nil, fmt.Errorf("consume referrals: %w", err) } Log(r).Infof("Consumed %d referrals for DID %s", toConsume, req.DID) + return + } - case *req.Count > index: - toAdd := *req.Count - index - refs = prepareReferralsToAdd(req.DID, toAdd, index) - if err = ReferralsQ(r).Insert(refs...); err != nil { - return - } - Log(r).Infof("Inserted %d referrals for DID %s", toAdd, req.DID) + index, err := ReferralsQ(r).FilterByUserDID(req.DID).Count() + if err != nil { + return nil, fmt.Errorf("count all referrals: %w", err) + } - default: - Log(r).Infof("No referrals to add or consume for DID %s", req.DID) + toAdd := *req.Count - active + // balance must exist, according to preceding logic in EditReferrals + err = ReferralsQ(r).Insert(prepareReferralsToAdd(req.DID, toAdd, index)...) + if err != nil { + return nil, fmt.Errorf("insert referrals: %w", err) } + Log(r).Infof("Inserted %d referrals for DID %s", toAdd, req.DID) + // while this is deterministic, the codes will be the same + refsAdded = referralid.NewMany(req.DID, toAdd, index) return } diff --git a/internal/service/handlers/fulfill_verify_proof_event.go b/internal/service/handlers/fulfill_verify_proof_event.go new file mode 100644 index 0000000..a09f242 --- /dev/null +++ b/internal/service/handlers/fulfill_verify_proof_event.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "fmt" + "net/http" + "time" + + "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" + api "github.com/rarimo/rarime-points-svc/pkg/connector" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +func FulfillVerifyProofEvent(w http.ResponseWriter, r *http.Request) { + req, err := requests.NewFulfillVerifyProofEvent(r) + if err != nil { + Log(r).WithError(err).Debug("Bad request") + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + log := Log(r).WithFields(map[string]any{ + "user_did": req.UserDID, + "proof_types": req.ProofTypes, + "verifier_did": req.VerifierDID, + }) + + owner, err := BalancesQ(r).FilterByDID(req.UserDID).Get() + if err != nil { + log.WithError(err).Error("Failed to get balance by DID") + ape.RenderErr(w, api.CodeInternalError.JSONAPIError()) + return + } + + // Normally should never happen + if owner == nil { + log.Error("Proof owner balance not exists") + w.WriteHeader(http.StatusNoContent) + return + } + + verifier, err := BalancesQ(r).FilterByDID(req.VerifierDID).Get() + if err != nil { + log.WithError(err).Error("Failed to get verifier balance by DID") + ape.RenderErr(w, api.CodeInternalError.JSONAPIError()) + return + } + + // If the verifier does not have a balance, then create it + if verifier == nil { + events := EventTypes(r).PrepareEvents(req.VerifierDID, evtypes.FilterNotOpenable) + typeExists := false + for i, ev := range events { + if eventTypeIsOneOfProofs(ev.Type, req.ProofTypes) { + events[i].Status = data.EventFulfilled + typeExists = true + break + } + } + + if !typeExists { + log.Debug("Event type is not openable") + ape.RenderErr(w, api.CodeEventNotFound.JSONAPIError()) + return + } + + if err = createBalanceWithEvents(req.VerifierDID, "", events, r); err != nil { + log.WithError(err).Error("Failed to create balance with events") + ape.RenderErr(w, api.CodeInternalError.JSONAPIError()) + return + } + + w.WriteHeader(http.StatusNoContent) + return + } + + err = EventsQ(r).Transaction(func() (err error) { + passportValid := verifier.PassportHash.Valid && verifier.PassportExpires.Time.After(time.Now().UTC()) + if passportValid { + log.Debugf("Verifier have valid passport.") + } + + for _, proof := range req.ProofTypes { + if err = verifyProofFulfill(r, req, req.VerifierDID, fmt.Sprintf("verify_proof_%s", proof)); err != nil { + return + } + if !passportValid { + continue + } + // The verifier must have a verified passport for the owner of the proof to receive points + err = verifyProofFulfill(r, req, req.UserDID, fmt.Sprintf("verified_proof_%s", proof)) + if err != nil { + return + } + } + + return + }) + if err != nil { + log.WithError(err).Error("Failed to fulfill verify proof events") + ape.RenderErr(w, api.CodeInternalError.JSONAPIError()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func verifyProofFulfill(r *http.Request, req api.FulfillVerifyProofEventRequest, did, evType string) (err error) { + log := Log(r).WithFields(map[string]any{ + "user_did": req.UserDID, + "event_name": evType, + "verifier_did": req.VerifierDID, + }) + + eventType := EventTypes(r).Get(evType, evtypes.FilterInactive) + if eventType == nil { + log.Debugf("Event %s inactive", evType) + return nil + } + + event, err := EventsQ(r). + FilterByUserDID(did). + FilterByType(evType). + FilterByStatus(data.EventOpen). + Get() + if err != nil { + return fmt.Errorf("get event %s by DID: %w", evType, err) + } + + if event == nil { + log.Debugf("Event %s absent or already fulfilled for user", evType) + return nil + } + + _, err = EventsQ(r).FilterByID(event.ID).Update(data.EventFulfilled, nil, nil) + if err != nil { + return fmt.Errorf("update event %s by ID: %w", evType, err) + } + + return nil +} + +func eventTypeIsOneOfProofs(eventType string, proofs []string) bool { + for _, proof := range proofs { + if eventType == fmt.Sprintf("verify_proof_%s", proof) { + return true + } + } + + return false +} diff --git a/internal/service/handlers/get_balance.go b/internal/service/handlers/get_balance.go index 294def5..261588e 100644 --- a/internal/service/handlers/get_balance.go +++ b/internal/service/handlers/get_balance.go @@ -44,7 +44,7 @@ func GetBalance(w http.ResponseWriter, r *http.Request) { var referrals []data.Referral if req.ReferralCodes { - referrals, err = ReferralsQ(r).FilterByUserDID(req.DID).FilterByIsConsumed(false).Select() + referrals, err = ReferralsQ(r).FilterByUserDID(req.DID).Select() if err != nil { Log(r).WithError(err).Error("Failed to get referrals by DID") ape.RenderErr(w, problems.InternalError()) @@ -73,15 +73,24 @@ func newBalanceModel(balance data.Balance) resources.Balance { } func newBalanceResponse(balance data.Balance, referrals []data.Referral) resources.BalanceResponse { - balanceResponse := resources.BalanceResponse{Data: newBalanceModel(balance)} + resp := resources.BalanceResponse{Data: newBalanceModel(balance)} + resp.Data.Attributes.IsWithdrawalAllowed = &balance.IsWithdrawalAllowed + if len(referrals) == 0 { - return balanceResponse + return resp } - referralCodes := make([]string, len(referrals)) - balanceResponse.Data.Attributes.ReferralCodes = &referralCodes - for i, referral := range referrals { - referralCodes[i] = referral.ID + activeCodes, consumedCodes := make([]string, 0, len(referrals)), make([]string, 0, len(referrals)) + resp.Data.Attributes.ActiveReferralCodes = &activeCodes + resp.Data.Attributes.ConsumedReferralCodes = &consumedCodes + + for _, ref := range referrals { + if ref.IsConsumed { + consumedCodes = append(consumedCodes, ref.ID) + continue + } + activeCodes = append(activeCodes, ref.ID) } - return balanceResponse + + return resp } diff --git a/internal/service/handlers/get_event.go b/internal/service/handlers/get_event.go new file mode 100644 index 0000000..a23e272 --- /dev/null +++ b/internal/service/handlers/get_event.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "net/http" + + "github.com/rarimo/auth-svc/pkg/auth" + "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" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +func GetEvent(w http.ResponseWriter, r *http.Request) { + id, err := requests.NewGetEvent(r) + if err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + event, err := EventsQ(r).FilterByID(id).Get() + if err != nil { + Log(r).WithError(err).Error("Failed to get event by ID") + ape.RenderErr(w, problems.InternalError()) + return + } + + if event == nil { + ape.RenderErr(w, problems.NotFound()) + return + } + + evType := EventTypes(r).Get(event.Type, evtypes.FilterInactive) + if evType == nil { + Log(r).Debugf("Event type is not active at this moment: %s", event.Type) + ape.RenderErr(w, problems.NotFound()) + return + } + + if !auth.Authenticates(UserClaims(r), auth.UserGrant(event.UserDID)) { + ape.RenderErr(w, problems.Unauthorized()) + return + } + + ape.Render(w, resources.EventResponse{Data: newEventModel(*event, evType.Resource())}) +} diff --git a/internal/service/handlers/list_events.go b/internal/service/handlers/list_events.go index 904486f..fca793b 100644 --- a/internal/service/handlers/list_events.go +++ b/internal/service/handlers/list_events.go @@ -35,7 +35,7 @@ func ListEvents(w http.ResponseWriter, r *http.Request) { FilterByStatus(req.FilterStatus...). FilterByType(req.FilterType...). FilterInactiveNotClaimed(inactiveTypes...). - Page(&req.CursorPageParams). + Page(&req.OffsetPageParams). Select() if err != nil { Log(r).WithError(err).Errorf("Failed to get filtered paginated event list: did=%s status=%v type=%v", @@ -67,13 +67,8 @@ func ListEvents(w http.ResponseWriter, r *http.Request) { return } - var last int32 - if len(events) > 0 { - last = events[len(events)-1].UpdatedAt - } - resp := newEventsResponse(events, meta) - resp.Links = req.CursorParams.GetLinks(r, last) + resp.Links = req.OffsetParams.GetLinks(r) if req.Count { _ = resp.PutMeta(struct { EventsCount int `json:"events_count"` diff --git a/internal/service/handlers/verify_passport.go b/internal/service/handlers/verify_passport.go index 590c2c2..13dbbc4 100644 --- a/internal/service/handlers/verify_passport.go +++ b/internal/service/handlers/verify_passport.go @@ -2,45 +2,67 @@ package handlers import ( "database/sql" - "encoding/json" "fmt" "net/http" + "time" "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/pkg/connector" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" ) func VerifyPassport(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + return + + // TODO: New logic. User go to public endpoint with proof that verify passport + // need logic for check proof and accruing points for verification. + req, err := requests.NewVerifyPassport(r) if err != nil { Log(r).WithError(err).Debug("Bad request") ape.RenderErr(w, problems.BadRequest(err)...) return } + log := Log(r).WithFields(map[string]any{ - "user_did": req.UserDID, - "hash": req.Hash, - "expiry": req.Expiry.String(), + "user_did": req.UserDID, + "hash": req.Hash, + "shared_data": req.SharedData, }) - balance, err := BalancesQ(r).FilterByDID(req.UserDID).Get() + balance, err := BalancesQ(r).FilterByPassportHash(req.Hash).Get() if err != nil { - log.WithError(err).Error("Failed to get balance by DID") + log.WithError(err).Error("Failed to get balance by Hash") ape.RenderErr(w, problems.InternalError()) return } - var reward int64 - logMsgScan := "PassportScan event type is disabled or expired, not accruing points" + if balance != nil && balance.DID != req.UserDID { + log.Error("passport_hash already in use") + ape.RenderErr(w, problems.Conflict()) + return + } + + if balance == nil { + balance, err = BalancesQ(r).FilterByDID(req.UserDID).Get() + if err != nil { + log.WithError(err).Error("Failed to get balance by DID") + ape.RenderErr(w, problems.InternalError()) + return + } + } + + var reward *int64 evType := EventTypes(r).Get(evtypes.TypePassportScan, evtypes.FilterInactive) if evType != nil { var success bool reward, success = EventTypes(r).CalculatePassportScanReward(req.SharedData...) if !success { - log.WithError(err).Error("Failed to calculate PassportScanReward, incorrect fields") + log.Error("Failed to calculate PassportScanReward, incorrect fields") ape.RenderErr(w, problems.NotFound()) return } @@ -48,35 +70,24 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { if balance == nil { log.Debug("Balance not found, creating new one") - events := EventTypes(r).PrepareEvents(req.UserDID, evtypes.FilterNotOpenable) - for i := 0; i < len(events); i++ { - if events[i].Type == evtypes.TypePassportScan { - events[i].PointsAmount = &reward - events[i].Status = data.EventFulfilled - } - } + err = createBalanceWithPassportTx(r, req, reward) - err = EventsQ(r).Transaction(func() error { - balance = &data.Balance{ - DID: req.UserDID, - PassportHash: sql.NullString{String: req.Hash, Valid: true}, - PassportExpires: sql.NullTime{Time: req.Expiry, Valid: true}, - } + if err != nil { + log.WithError(err).Error("Failed to create balance with events") + ape.RenderErr(w, problems.InternalError()) + return + } - if err = BalancesQ(r).Insert(*balance); err != nil { - return fmt.Errorf("add balance: %w", err) - } + w.WriteHeader(http.StatusNoContent) + return + } - log.Debugf("%d events will be added for user_did=%s", len(events), req.UserDID) - if err = EventsQ(r).Insert(events...); err != nil { - return fmt.Errorf("add open events: %w", err) - } - return nil - }) + if !balance.PassportHash.Valid { + err = setBalancePassportTx(r, req, reward, balance.ReferredBy) if err != nil { - log.WithError(err).Error("Failed to create balance with events") + log.WithError(err).Error("Failed to set passport and add event for referrer") ape.RenderErr(w, problems.InternalError()) return } @@ -85,55 +96,96 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { return } - err = EventsQ(r).Transaction(func() error { - // If you make this endpoint public, you should check the passport hash for - // uniqueness and provide a better validation. Think about other changes too. - err = BalancesQ(r).FilterByDID(req.UserDID).SetPassport(req.Hash, req.Expiry) + err = BalancesQ(r).FilterByDID(req.UserDID).SetPassport(balance.PassportHash.String, time.Now().UTC().AddDate(0, 1, 0), !req.IsUSA) + if err != nil { + log.WithError(err).Error("Failed to update passport") + ape.RenderErr(w, problems.InternalError()) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func createBalanceWithPassportTx(r *http.Request, req connector.VerifyPassportRequest, reward *int64) error { + events := EventTypes(r).PrepareEvents(req.UserDID, evtypes.FilterNotOpenable) + + log := Log(r).WithFields(map[string]any{ + "user_did": req.UserDID, + "hash": req.Hash, + "shared_data": req.SharedData, + }) + + for i := 0; i < len(events); i++ { + if events[i].Type == evtypes.TypePassportScan { + events[i].PointsAmount = reward + events[i].Status = data.EventFulfilled + } + } + + return EventsQ(r).Transaction(func() (err error) { + balance := &data.Balance{ + DID: req.UserDID, + PassportHash: sql.NullString{String: req.Hash, Valid: true}, + PassportExpires: sql.NullTime{Time: time.Now().UTC().AddDate(0, 1, 0), Valid: true}, + IsWithdrawalAllowed: !req.IsUSA, + } + + if err = BalancesQ(r).Insert(*balance); err != nil { + return fmt.Errorf("add balance: %w", err) + } + + log.Debugf("%d events will be added for user_did=%s", len(events), req.UserDID) + if err = EventsQ(r).Insert(events...); err != nil { + return fmt.Errorf("add open events: %w", err) + } + return nil + }) +} + +func setBalancePassportTx(r *http.Request, req connector.VerifyPassportRequest, reward *int64, refBy sql.NullString) error { + log := Log(r).WithFields(map[string]any{ + "user_did": req.UserDID, + "hash": req.Hash, + "shared_data": req.SharedData, + }) + return EventsQ(r).Transaction(func() error { + err := BalancesQ(r).FilterByDID(req.UserDID).SetPassport(req.Hash, time.Now().UTC().AddDate(0, 1, 0), !req.IsUSA) if err != nil { return fmt.Errorf("set passport for balance by DID: %w", err) } - if evType != nil { - passportScanEvent, err := EventsQ(r). - FilterByUserDID(req.UserDID). - FilterByType(evtypes.TypePassportScan). - FilterByStatus(data.EventOpen). - Get() - if err != nil { - return fmt.Errorf("get passport_scan event by DID: %w", err) - } - logMsgOpenE := "PassportScan event not open" - if passportScanEvent != nil { - _, err = EventsQ(r). - FilterByUserDID(req.UserDID). - FilterByType(evtypes.TypePassportScan). - Update(data.EventFulfilled, json.RawMessage(passportScanEvent.Meta), &reward) - if err != nil { - return fmt.Errorf("update reward for passport_scan event by DID: %w", err) - } - logMsgOpenE = "PassportScan event open" - logMsgScan = "PassportScan event reward update successful" + logMsgScan := "PassportScan event type is disabled or expired, not accruing points" + if reward != nil { + logMsgScan = "PassportScan event type available" + if err = fulfillPassportScanEvent(r, req, reward); err != nil { + return fmt.Errorf("fulfill passport scan event for user: %w", err) } - log.Debug(logMsgOpenE) } log.Debug(logMsgScan) - evType = EventTypes(r).Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) + if !refBy.Valid { + log.Debug("User balance incative") + return nil + } + + evType := EventTypes(r).Get(evtypes.TypeReferralSpecific, evtypes.FilterInactive) if evType == nil { log.Debug("Referral event type is disabled or expired, not accruing points to referrer") return nil } - refDID, err := getReferrerDID(*balance, r) + ref, err := ReferralsQ(r).Get(refBy.String) if err != nil { - return fmt.Errorf("get referrer DID by referred_by: %w", err) + return fmt.Errorf("get referral: %w", err) } - if refDID == "" { - return nil + + // normally should never happen + if ref == nil { + return fmt.Errorf("referral code not found") } err = EventsQ(r).Insert(data.Event{ - UserDID: refDID, + UserDID: ref.UserDID, Type: evType.Name, Status: data.EventFulfilled, Meta: data.Jsonb(fmt.Sprintf(`{"did": "%s"}`, req.UserDID)), @@ -144,22 +196,37 @@ func VerifyPassport(w http.ResponseWriter, r *http.Request) { return nil }) +} - if err != nil { - log.WithError(err).Error("Failed to set passport and add event for referrer") - ape.RenderErr(w, problems.InternalError()) - return - } +func fulfillPassportScanEvent(r *http.Request, req connector.VerifyPassportRequest, reward *int64) error { + log := Log(r).WithFields(map[string]any{ + "user_did": req.UserDID, + "hash": req.Hash, + "shared_data": req.SharedData, + }) - w.WriteHeader(http.StatusNoContent) -} + passportScanEvent, err := EventsQ(r). + FilterByUserDID(req.UserDID). + FilterByType(evtypes.TypePassportScan). + FilterByStatus(data.EventOpen). + Get() -// TODO: implement new referrals flow -func getReferrerDID(balance data.Balance, r *http.Request) (string, error) { - if !balance.ReferredBy.Valid { - return "", nil + if err != nil { + return fmt.Errorf("get passport_scan event by DID: %w", err) } - refBy := balance.ReferredBy.String - return refBy, nil + if passportScanEvent != nil { + log.Debug("PassportScan event open") + _, err = EventsQ(r). + FilterByUserDID(req.UserDID). + FilterByType(evtypes.TypePassportScan). + Update(data.EventFulfilled, nil, reward) + if err != nil { + return fmt.Errorf("update reward for passport_scan event by DID: %w", err) + } + log.Debug("PassportScan event reward update successful") + return nil + } + log.Debug("PassportScan event not open") + return nil } diff --git a/internal/service/handlers/withdraw.go b/internal/service/handlers/withdraw.go index e702fd6..3759436 100644 --- a/internal/service/handlers/withdraw.go +++ b/internal/service/handlers/withdraw.go @@ -121,6 +121,8 @@ func isEligibleToWithdraw(balance *data.Balance, amount int64) error { return mapValidationErr("is_verified", "user must have verified passport to withdraw") case balance.PassportExpires.Time.Before(time.Now().UTC()): return mapValidationErr("is_verified", "user passport is expired") + case !balance.IsWithdrawalAllowed: + return mapValidationErr("is_withdrawal_allowed", "withdrawal ability was disabled for this user") case balance.Amount < amount: return mapValidationErr("data/attributes/amount", "insufficient balance: %d", balance.Amount) } diff --git a/internal/service/requests/activate_balance.go b/internal/service/requests/activate_balance.go new file mode 100644 index 0000000..8344fc6 --- /dev/null +++ b/internal/service/requests/activate_balance.go @@ -0,0 +1,26 @@ +package requests + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rarimo/rarime-points-svc/resources" +) + +func NewActivateBalance(r *http.Request) (req resources.CreateBalanceRequest, err error) { + did := chi.URLParam(r, "did") + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + err = newDecodeError("body", err) + return + } + + errs := validation.Errors{ + "data/id": validation.Validate(req.Data.ID, validation.Required, validation.In(did)), + "data/type": validation.Validate(req.Data.Type, validation.Required, validation.In(resources.UPDATE_BALANCE)), + "data/attributes/referred_by": validation.Validate(req.Data.Attributes.ReferredBy, validation.Required), + } + + return req, errs.Filter() +} diff --git a/internal/service/requests/edit_referrals.go b/internal/service/requests/edit_referrals.go index 17d02f4..f3c9015 100644 --- a/internal/service/requests/edit_referrals.go +++ b/internal/service/requests/edit_referrals.go @@ -20,6 +20,6 @@ func NewEditReferrals(r *http.Request) (req EditReferralsRequest, err error) { return req, validation.Errors{ "did": validation.Validate(req.DID, validation.Required), - "count": validation.Validate(req.Count, validation.Required), + "count": validation.Validate(req.Count, validation.NotNil), }.Filter() } diff --git a/internal/service/requests/fulfill_verify_proof_event.go b/internal/service/requests/fulfill_verify_proof_event.go new file mode 100644 index 0000000..acc423b --- /dev/null +++ b/internal/service/requests/fulfill_verify_proof_event.go @@ -0,0 +1,22 @@ +package requests + +import ( + "encoding/json" + "net/http" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rarimo/rarime-points-svc/pkg/connector" +) + +func NewFulfillVerifyProofEvent(r *http.Request) (req connector.FulfillVerifyProofEventRequest, err error) { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + err = newDecodeError("body", err) + return + } + + return req, validation.Errors{ + "user_did": validation.Validate(req.UserDID, validation.Required), + "proof_types": validation.Validate(req.ProofTypes, validation.Required), + "verifier_did": validation.Validate(req.VerifierDID, validation.Required), + }.Filter() +} diff --git a/internal/service/requests/get_event.go b/internal/service/requests/get_event.go new file mode 100644 index 0000000..dda1840 --- /dev/null +++ b/internal/service/requests/get_event.go @@ -0,0 +1,14 @@ +package requests + +import ( + "net/http" + + "github.com/go-chi/chi" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +func NewGetEvent(r *http.Request) (id string, err error) { + id = chi.URLParam(r, "id") + return id, validation.Errors{"id": validation.Validate(id, validation.Required)}. + Filter() +} diff --git a/internal/service/requests/list_events.go b/internal/service/requests/list_events.go index 08fda0d..0e030fa 100644 --- a/internal/service/requests/list_events.go +++ b/internal/service/requests/list_events.go @@ -10,7 +10,7 @@ import ( ) type ListEvents struct { - page.CursorParams + page.OffsetParams FilterDID *string `filter:"did"` FilterStatus []data.EventStatus `filter:"status"` FilterType []string `filter:"meta.static.name"` @@ -22,7 +22,7 @@ func NewListEvents(r *http.Request) (req ListEvents, err error) { err = newDecodeError("query", err) return } - if err = req.CursorParams.Validate(); err != nil { + if err = req.OffsetParams.Validate(); err != nil { return } diff --git a/internal/service/requests/verify_passport.go b/internal/service/requests/verify_passport.go index da18598..ef395ce 100644 --- a/internal/service/requests/verify_passport.go +++ b/internal/service/requests/verify_passport.go @@ -2,9 +2,7 @@ package requests import ( "encoding/json" - "errors" "net/http" - "time" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/rarimo/rarime-points-svc/pkg/connector" @@ -19,20 +17,6 @@ func NewVerifyPassport(r *http.Request) (req connector.VerifyPassportRequest, er return req, validation.Errors{ "user_did": validation.Validate(req.UserDID, validation.Required), "hash": validation.Validate(req.Hash, validation.Required), - "expiry": validation.Validate(req.Expiry, validation.Required, validation.By(isNotExpiredRule)), "shared_data": validation.Validate(req.SharedData, validation.Required, validation.Length(2, 0)), }.Filter() } - -func isNotExpiredRule(value interface{}) error { - v, ok := value.(time.Time) - if !ok { - panic("value is not a time.Time") // invalid function usage - } - - if v.Before(time.Now().UTC()) { - return errors.New("expiry is in the past") - } - - return nil -} diff --git a/internal/service/router.go b/internal/service/router.go index 355df6a..0ecff25 100644 --- a/internal/service/router.go +++ b/internal/service/router.go @@ -30,6 +30,8 @@ func Run(ctx context.Context, cfg config.Config) { r.Post("/", handlers.CreateBalance) r.Route("/{did}", func(r chi.Router) { r.Get("/", handlers.GetBalance) + r.Patch("/", handlers.ActivateBalance) + r.Patch("/verifypassport", handlers.VerifyPassport) r.Get("/withdrawals", handlers.ListWithdrawals) r.Post("/withdrawals", handlers.Withdraw) }) @@ -37,6 +39,7 @@ func Run(ctx context.Context, cfg config.Config) { r.Route("/events", func(r chi.Router) { r.Use(handlers.AuthMiddleware(cfg.Auth(), cfg.Log())) r.Get("/", handlers.ListEvents) + r.Get("/{id}", handlers.GetEvent) r.Patch("/{id}", handlers.ClaimEvent) }) r.Get("/balances", handlers.Leaderboard) @@ -44,8 +47,8 @@ func Run(ctx context.Context, cfg config.Config) { }) // must be accessible only within the cluster r.Route("/private", func(r chi.Router) { - r.Patch("/balances", handlers.VerifyPassport) r.Patch("/events", handlers.FulfillEvent) + r.Patch("/proofs", handlers.FulfillVerifyProofEvent) r.Post("/referrals", handlers.EditReferrals) }) }) diff --git a/internal/service/workers/expirywatch/main.go b/internal/service/workers/expirywatch/main.go index f1a6229..5f2616c 100644 --- a/internal/service/workers/expirywatch/main.go +++ b/internal/service/workers/expirywatch/main.go @@ -12,6 +12,7 @@ import ( ) const retryPeriod = 1 * time.Minute +const maxRetries = 12 func Run(ctx context.Context, cfg config.Config) { w := newWatcher(cfg) diff --git a/internal/service/workers/expirywatch/watcher.go b/internal/service/workers/expirywatch/watcher.go index 8c64d06..23d99ea 100644 --- a/internal/service/workers/expirywatch/watcher.go +++ b/internal/service/workers/expirywatch/watcher.go @@ -58,5 +58,5 @@ func (w *watcher) job(ctx context.Context, evType string) { return false, fmt.Errorf("clean open events: %w", err) } return true, nil - }, retryPeriod, retryPeriod, 12) + }, retryPeriod, retryPeriod, maxRetries) } diff --git a/internal/service/workers/reopener/init.go b/internal/service/workers/reopener/init.go index 3e0722e..839f65a 100644 --- a/internal/service/workers/reopener/init.go +++ b/internal/service/workers/reopener/init.go @@ -1,14 +1,18 @@ package reopener import ( + "context" "fmt" "time" + "github.com/go-co-op/gocron/v2" "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/workers/cron" "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/running" ) func initialRun(cfg config.Config) error { @@ -115,3 +119,63 @@ func (c *initCollector) selectAbsent() ([]data.ReopenableEvent, error) { log.Infof("%d new (DID, type) pairs to open: %v", len(res), res) return res, nil } + +func runStartingWatchers(ctx context.Context, cfg config.Config) error { + log := cfg.Log().WithField("who", "opener[initializer]") + + notStartedEv := cfg.EventTypes().List(func(ev evtypes.EventConfig) bool { + return ev.Disabled || !evtypes.FilterNotStarted(ev) || evtypes.FilterExpired(ev) + }) + + if len(notStartedEv) == 0 { + log.Info("No events to open at Start time: all types already opened or there no types with StartAt") + return nil + } + + for _, ev := range notStartedEv { + _, err := cron.NewJob( + gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(*ev.StartsAt)), + gocron.NewTask(startingWatcher(cfg, ev.Name), ctx), + gocron.WithName(fmt.Sprintf("opener[%s]", ev.Name)), + ) + + if err != nil { + return fmt.Errorf("opener: failed to initialize job: %w", err) + } + } + + return nil +} + +func startingWatcher(cfg config.Config, name string) func(context.Context) { + return func(ctx context.Context) { + log := cfg.Log().WithField("who", fmt.Sprintf("opener[%s]", name)) + + var balances []data.Balance + var err error + + running.WithThreshold(ctx, log, fmt.Sprintf("opener[%s]", name), func(context.Context) (bool, error) { + if balances, err = pg.NewBalances(cfg.DB().Clone()).Select(); err != nil { + return false, err + } + return true, nil + }, retryPeriod, retryPeriod, maxRetries) + + events := make([]data.Event, len(balances)) + status := data.EventOpen + if name == evtypes.TypeFreeWeekly { + status = data.EventFulfilled + } + + for i, balance := range balances { + events[i] = data.Event{UserDID: balance.DID, Type: name, Status: status} + } + + running.WithThreshold(ctx, log, fmt.Sprintf("opener[%s]", name), func(context.Context) (bool, error) { + if err = pg.NewEvents(cfg.DB().Clone()).Insert(events...); err != nil { + return false, err + } + return true, nil + }, retryPeriod, retryPeriod, maxRetries) + } +} diff --git a/internal/service/workers/reopener/main.go b/internal/service/workers/reopener/main.go index ee7b530..54ddce5 100644 --- a/internal/service/workers/reopener/main.go +++ b/internal/service/workers/reopener/main.go @@ -13,6 +13,7 @@ import ( ) const retryPeriod = 5 * time.Minute +const maxRetries = 12 func Run(ctx context.Context, cfg config.Config) { if err := initialRun(cfg); err != nil { @@ -20,6 +21,11 @@ func Run(ctx context.Context, cfg config.Config) { } cron.Init(cfg.Log()) + + if err := runStartingWatchers(ctx, cfg); err != nil { + panic(fmt.Errorf("reopener: failed to initialize opener: %w", err)) + } + atDayStart := gocron.NewAtTimes(gocron.NewAtTime(0, 0, 0)) daily := newWorker(cfg, evtypes.Daily) diff --git a/internal/service/workers/reopener/worker.go b/internal/service/workers/reopener/worker.go index e0b4285..716a24d 100644 --- a/internal/service/workers/reopener/worker.go +++ b/internal/service/workers/reopener/worker.go @@ -45,7 +45,7 @@ func (w *worker) job(ctx context.Context) { return false, fmt.Errorf("reopen events: %w", err) } return true, nil - }, retryPeriod, retryPeriod, 12) + }, retryPeriod, retryPeriod, maxRetries) } func (w *worker) reopenEvents(types []string) error { diff --git a/internal/service/workers/sbtcheck/main.go b/internal/service/workers/sbtcheck/main.go index e2be16c..854d7f2 100644 --- a/internal/service/workers/sbtcheck/main.go +++ b/internal/service/workers/sbtcheck/main.go @@ -54,7 +54,11 @@ type extConfig interface { func Run(ctx context.Context, cfg extConfig) { log := cfg.Log().WithField("who", "sbt-checker") - getPoh := cfg.EventTypes().Get(evtypes.TypeGetPoH, evtypes.FilterInactive) + + // FilterInactive filter also events which hasn't start yet. Then we need filter only events which will not be active. + // Events with StartsAt will be open in specified time + getPoh := cfg.EventTypes().Get(evtypes.TypeGetPoH, + func(ev evtypes.EventConfig) bool { return ev.Disabled || evtypes.FilterExpired(ev) }) if getPoh == nil { log.Warn("PoH event is disabled or expired, SBT check will not run") return @@ -71,6 +75,12 @@ func Run(ctx context.Context, cfg extConfig) { }) } + if start := getPoh.StartsAt; start != nil && start.After(time.Now().UTC()) { + until := start.Sub(time.Now().UTC()) + timer := time.NewTimer(until) + <-timer.C + } + var wg sync.WaitGroup for name, net := range cfg.SbtCheck().networks { if net.disabled { diff --git a/pkg/connector/config.go b/pkg/connector/config.go index 9052ef1..32bfcd2 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -9,6 +9,7 @@ import ( "gitlab.com/distributed_lab/figure/v3" "gitlab.com/distributed_lab/kit/comfig" "gitlab.com/distributed_lab/kit/kv" + "gitlab.com/distributed_lab/logan/v3" ) const defaultTimeout = 10 * time.Second @@ -28,12 +29,27 @@ func NewPointer(getter kv.Getter) Pointer { func (p *points) Points() *Client { return p.once.Do(func() any { + var disabledConfig struct { + Disabled bool `fig:"disabled"` + } + + err := figure.Out(&disabledConfig). + From(kv.MustGetStringMap(p.getter, "points")). + Please() + if err != nil { + panic(fmt.Errorf("failed to figure out disabled for points: %s", err)) + } + + if disabledConfig.Disabled { + return &Client{disabled: true, log: logan.New()} + } + var cfg struct { Addr *url.URL `fig:"addr,required"` RequestTimeout time.Duration `fig:"request_timeout"` } - err := figure.Out(&cfg). + err = figure.Out(&cfg). From(kv.MustGetStringMap(p.getter, "points")). Please() if err != nil { diff --git a/pkg/connector/main.go b/pkg/connector/main.go index 6ac1161..ae51ead 100644 --- a/pkg/connector/main.go +++ b/pkg/connector/main.go @@ -11,19 +11,27 @@ import ( conn "gitlab.com/distributed_lab/json-api-connector" "gitlab.com/distributed_lab/json-api-connector/cerrors" iface "gitlab.com/distributed_lab/json-api-connector/client" + "gitlab.com/distributed_lab/logan/v3" ) const privatePrefix = "/integrations/rarime-points-svc/v1/private" type Client struct { - conn *conn.Connector + disabled bool + log *logan.Entry + conn *conn.Connector } func NewClient(cli iface.Client) *Client { - return &Client{conn: conn.NewConnector(cli)} + return &Client{conn: conn.NewConnector(cli), log: logan.New()} } func (c *Client) FulfillEvent(ctx context.Context, req FulfillEventRequest) *Error { + if c.disabled { + c.log.Info("Points connector disabled") + return nil + } + u, _ := url.Parse(privatePrefix + "/events") err := c.conn.PatchJSON(u, req, ctx, nil) @@ -45,7 +53,41 @@ func (c *Client) FulfillEvent(ctx context.Context, req FulfillEventRequest) *Err } } +func (c *Client) FulfillVerifyProofEvent(ctx context.Context, req FulfillVerifyProofEventRequest) *Error { + if c.disabled { + c.log.Info("Points connector disabled") + return nil + } + + u, _ := url.Parse(privatePrefix + "/proofs") + + err := c.conn.PatchJSON(u, req, ctx, nil) + if err == nil { + return nil + } + + baseErr := err + code, err := extractErrCode(err) + if err != nil { + return &Error{ + err: fmt.Errorf("failed to extract error code: %w; base error: %w", err, baseErr), + } + } + + return &Error{ + Code: code, + err: baseErr, + } +} + func (c *Client) VerifyPassport(ctx context.Context, req VerifyPassportRequest) error { + // Deprecated: VerifyPassport whould be public endpoint + // and that connector currently not used + if c.disabled { + c.log.Info("Points connector disabled") + return nil + } + u, _ := url.Parse(privatePrefix + "/balances") return c.conn.PatchJSON(u, req, ctx, nil) } diff --git a/pkg/connector/models.go b/pkg/connector/models.go index 1eca146..4147a8e 100644 --- a/pkg/connector/models.go +++ b/pkg/connector/models.go @@ -3,7 +3,6 @@ package connector import ( "net/http" "strconv" - "time" "github.com/google/jsonapi" ) @@ -14,11 +13,17 @@ type FulfillEventRequest struct { ExternalID *string `json:"external_id,omitempty"` } +type FulfillVerifyProofEventRequest struct { + UserDID string `json:"user_did"` + ProofTypes []string `json:"proof_types"` + VerifierDID string `json:"verifier_did"` +} + type VerifyPassportRequest struct { - UserDID string `json:"user_did"` - Hash string `json:"hash"` - Expiry time.Time `json:"expiry"` - SharedData []string `json:"shared_data"` + UserDID string `json:"user_did"` + Hash string `json:"hash"` + SharedData []string `json:"shared_data"` + IsUSA bool `json:"is_usa"` } // ErrorCode represents an error with a code indicating the unhappy flow that occurred diff --git a/resources/db.go b/resources/db.go index 0c07f2f..5381330 100644 --- a/resources/db.go +++ b/resources/db.go @@ -12,7 +12,7 @@ import ( "gitlab.com/distributed_lab/logan/v3/errors" ) -//driverValue - converts interface into db supported type +// driverValue - converts interface into db supported type func driverValue(data interface{}) (driver.Value, error) { data, err := json.Marshal(data) if err != nil { @@ -22,7 +22,7 @@ func driverValue(data interface{}) (driver.Value, error) { return data, nil } -//driveScan - converts jsonb into type struct +// driveScan - converts jsonb into type struct func driveScan(src, dest interface{}) error { data, err := convertJSONB(src) if err != nil { diff --git a/resources/included.go b/resources/included.go index 889272d..68d70a2 100644 --- a/resources/included.go +++ b/resources/included.go @@ -46,7 +46,7 @@ func (c *Included) add(include Resource) { c.includes[include.GetKey()] = json.RawMessage(data) } -//MarshalJSON - marshals include collection as array of json objects +// MarshalJSON - marshals include collection as array of json objects func (c Included) MarshalJSON() ([]byte, error) { uniqueEntries := make([]json.RawMessage, 0, len(c.includes)) for _, value := range c.includes { @@ -56,7 +56,7 @@ func (c Included) MarshalJSON() ([]byte, error) { return json.Marshal(uniqueEntries) } -//UmarshalJSON - unmarshal array of json objects into include collection +// UmarshalJSON - unmarshal array of json objects into include collection func (c *Included) UnmarshalJSON(data []byte) error { var keys []Key err := json.Unmarshal(data, &keys) diff --git a/resources/model_balance_attributes.go b/resources/model_balance_attributes.go index 24478b1..7df05b4 100644 --- a/resources/model_balance_attributes.go +++ b/resources/model_balance_attributes.go @@ -5,18 +5,22 @@ package resources type BalanceAttributes struct { + // Referral codes which can be used to build a referral link and send it to friends. Returned only for the single user. + ActiveReferralCodes *[]string `json:"active_referral_codes,omitempty"` // Amount of points Amount int64 `json:"amount"` + // Referral codes used by invited users. Returned only for the single user. + ConsumedReferralCodes *[]string `json:"consumed_referral_codes,omitempty"` // Unix timestamp of balance creation CreatedAt int32 `json:"created_at"` // Whether the user was not referred by anybody, but the balance with some events was reserved. It happens when the user fulfills some event before the balance creation. IsDisabled bool `json:"is_disabled"` // Whether the user has scanned passport IsVerified bool `json:"is_verified"` + // Whether the user can withdraw tokens. Returned only for the single user. + IsWithdrawalAllowed *bool `json:"is_withdrawal_allowed,omitempty"` // Rank of the user in the full leaderboard. Returned only for the single user. Rank *int `json:"rank,omitempty"` - // Referral codes used to build a referral link and send it to friends - ReferralCodes *[]string `json:"referral_codes,omitempty"` // Unix timestamp of the last points accruing UpdatedAt int32 `json:"updated_at"` } diff --git a/resources/model_details.go b/resources/model_details.go index 303c93d..36e0d24 100644 --- a/resources/model_details.go +++ b/resources/model_details.go @@ -13,7 +13,7 @@ import ( type Details json.RawMessage -//UnmarshalJSON - casts data to Details +// UnmarshalJSON - casts data to Details func (d *Details) UnmarshalJSON(data []byte) error { if d == nil { return errors.New("regources.Details: UnmarshalJSON on nil pointer") @@ -22,7 +22,7 @@ func (d *Details) UnmarshalJSON(data []byte) error { return nil } -//MarshalJSON - casts Details to []byte +// MarshalJSON - casts Details to []byte func (d Details) MarshalJSON() ([]byte, error) { if d == nil { return []byte("null"), nil @@ -34,7 +34,7 @@ func (d Details) String() string { return string(d) } -//Value - implements db driver method for auto marshal +// Value - implements db driver method for auto marshal func (r Details) Value() (driver.Value, error) { result, err := json.Marshal(r) if err != nil { @@ -44,7 +44,7 @@ func (r Details) Value() (driver.Value, error) { return result, nil } -//Scan - implements db driver method for auto unmarshal +// Scan - implements db driver method for auto unmarshal func (r *Details) Scan(src interface{}) error { var data []byte switch rawData := src.(type) { diff --git a/resources/model_event_static_meta.go b/resources/model_event_static_meta.go index 16639e5..0827410 100644 --- a/resources/model_event_static_meta.go +++ b/resources/model_event_static_meta.go @@ -8,14 +8,21 @@ import "time" // Primary event metadata in plain JSON. This is a template to be filled by `dynamic` when it's present. type EventStaticMeta struct { - Description string `json:"description"` + // Page where you can fulfill the event + ActionUrl *string `json:"action_url,omitempty"` + Description string `json:"description"` // General event expiration date (UTC RFC3339) ExpiresAt *time.Time `json:"expires_at,omitempty"` // Event frequency, which means how often you can fulfill certain task and claim the reward. Frequency string `json:"frequency"` + // Event logo + Logo *string `json:"logo,omitempty"` // Unique event code name Name string `json:"name"` // Reward amount in points - Reward int64 `json:"reward"` - Title string `json:"title"` + Reward int64 `json:"reward"` + ShortDescription string `json:"short_description"` + // General event starting date (UTC RFC3339) + StartsAt *time.Time `json:"starts_at,omitempty"` + Title string `json:"title"` } diff --git a/resources/model_resource_type.go b/resources/model_resource_type.go index a236d96..dbb7e9f 100644 --- a/resources/model_resource_type.go +++ b/resources/model_resource_type.go @@ -11,6 +11,7 @@ const ( BALANCE ResourceType = "balance" CLAIM_EVENT ResourceType = "claim_event" CREATE_BALANCE ResourceType = "create_balance" + UPDATE_BALANCE ResourceType = "update_balance" EVENT ResourceType = "event" POINT_PRICE ResourceType = "point_price" WITHDRAW ResourceType = "withdraw"