diff --git a/Dockerfile b/Dockerfile index 8726690..ecee09b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine as buildbase +FROM golang:1.22-alpine as buildbase RUN apk add git build-base diff --git a/config.yaml b/config.yaml index c2c8b36..5a36703 100644 --- a/config.yaml +++ b/config.yaml @@ -20,24 +20,36 @@ event_types: - name: get_poh title: Get PoH credential reward: 50 - description: Lorem ipsum dolor sit amet + description: Prove that you are human frequency: one-time expires_at: 2020-01-01T00:00:00Z - name: free_weekly title: Free weekly points reward: 100 frequency: weekly - description: Lorem ipsum dolor sit amet + description: Get free points every week by visiting the platform and claiming your reward - name: daily_login title: Daily login reward: 5 frequency: daily - description: Lorem ipsum dolor sit amet - - name: verify_proof - title: Verify proof {:id} - reward: 10 + description: Login every day + 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 + 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 + - name: referral_specific + title: Refer user {:did} + reward: 25 frequency: unlimited - description: Lorem ipsum dolor sit amet + description: The user {:did} has verified the passport. Claim the reward! no_auto_open: true auth: diff --git a/docs/spec/components/schemas/Balance.yaml b/docs/spec/components/schemas/Balance.yaml index 4cd5ff8..4974bfa 100644 --- a/docs/spec/components/schemas/Balance.yaml +++ b/docs/spec/components/schemas/Balance.yaml @@ -8,6 +8,7 @@ allOf: type: object required: - amount + - referral_id - is_verified - created_at - updated_at @@ -17,6 +18,10 @@ allOf: format: int64 description: Amount of points example: 580 + referral_id: + type: string + description: Referral ID used to build a referral link and send it to friends + example: "zgsScguZ" is_verified: type: boolean description: Whether the user has scanned passport diff --git a/docs/spec/components/schemas/CreateBalance.yaml b/docs/spec/components/schemas/CreateBalance.yaml new file mode 100644 index 0000000..e3db56c --- /dev/null +++ b/docs/spec/components/schemas/CreateBalance.yaml @@ -0,0 +1,14 @@ +allOf: + - $ref: '#/components/schemas/CreateBalanceKey' + - type: object + x-go-is-request: true + properties: + attributes: + type: object + required: + - referred_by + properties: + referred_by: + type: string + description: ID of the referrer from the link + example: "rCx18MZ4" diff --git a/docs/spec/components/schemas/EventStaticMeta.yaml b/docs/spec/components/schemas/EventStaticMeta.yaml index ba49404..f699917 100644 --- a/docs/spec/components/schemas/EventStaticMeta.yaml +++ b/docs/spec/components/schemas/EventStaticMeta.yaml @@ -30,13 +30,7 @@ properties: description: | Event frequency, which means how often you can fulfill certain task and claim the reward. - enum: [one-time, daily, weekly, unlimited, custom] - no_auto_open: - type: boolean - description: | - If true, the event will not be created with `open` status automatically - when user creates the balance. - example: true + enum: [one-time, daily, weekly, unlimited] expires_at: type: string format: time.Time diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@balances.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@balances.yaml index 3cacf85..2233bb9 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@balances.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@balances.yaml @@ -16,7 +16,7 @@ post: - data properties: data: - $ref: '#/components/schemas/CreateBalanceKey' + $ref: '#/components/schemas/CreateBalance' responses: 201: description: Created @@ -31,6 +31,8 @@ post: $ref: '#/components/schemas/Balance' 401: $ref: '#/components/responses/invalidAuth' + 404: + $ref: '#/components/responses/notFound' 409: description: Balance already exists for provided DID content: diff --git a/docs/spec/paths/integrations@rarime-points-svc@v1@events@{id}.yaml b/docs/spec/paths/integrations@rarime-points-svc@v1@events@{id}.yaml index 864447f..a65de9f 100644 --- a/docs/spec/paths/integrations@rarime-points-svc@v1@events@{id}.yaml +++ b/docs/spec/paths/integrations@rarime-points-svc@v1@events@{id}.yaml @@ -45,6 +45,12 @@ patch: $ref: '#/components/responses/invalidParameter' 401: $ref: '#/components/responses/invalidAuth' + 403: + description: This event type was disabled and cannot be claimed + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/Errors' 404: $ref: '#/components/responses/notFound' 500: diff --git a/internal/assets/migrations/001_initial.sql b/internal/assets/migrations/001_initial.sql index 84f3107..5a5f34d 100644 --- a/internal/assets/migrations/001_initial.sql +++ b/internal/assets/migrations/001_initial.sql @@ -6,9 +6,11 @@ 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()), + 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()), + referral_id text UNIQUE NOT NULL, + referred_by text REFERENCES balances (referral_id), passport_hash text UNIQUE, passport_expires timestamp without time zone ); @@ -25,12 +27,12 @@ CREATE TYPE event_status AS ENUM ('open', 'fulfilled', 'claimed'); CREATE TABLE IF NOT EXISTS events ( - id uuid PRIMARY KEY not null default gen_random_uuid(), - user_did text not null REFERENCES balances (did), - type text not null, - status event_status not null, - created_at integer not null default EXTRACT('EPOCH' FROM NOW()), - updated_at integer not null default EXTRACT('EPOCH' FROM NOW()), + id uuid PRIMARY KEY NOT NULL default gen_random_uuid(), + user_did text NOT NULL REFERENCES balances (did), + type text NOT NULL, + status event_status NOT NULL, + created_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), + updated_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()), meta jsonb, points_amount integer ); @@ -48,10 +50,10 @@ EXECUTE FUNCTION trigger_set_updated_at(); CREATE TABLE IF NOT EXISTS withdrawals ( id uuid PRIMARY KEY default gen_random_uuid(), - user_did text not null REFERENCES balances (did), - amount integer not null, - address text not null, - created_at integer not null default EXTRACT('EPOCH' FROM NOW()) + user_did text NOT NULL REFERENCES balances (did), + amount integer NOT NULL, + address text NOT NULL, + created_at integer NOT NULL default EXTRACT('EPOCH' FROM NOW()) ); CREATE INDEX IF NOT EXISTS withdrawals_user_did_index ON withdrawals using btree (user_did); diff --git a/internal/cli/main.go b/internal/cli/main.go index ad10161..6481a73 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -10,6 +10,7 @@ import ( "github.com/alecthomas/kingpin" "github.com/rarimo/rarime-points-svc/internal/config" "github.com/rarimo/rarime-points-svc/internal/service" + "github.com/rarimo/rarime-points-svc/internal/service/workers/expirywatch" "github.com/rarimo/rarime-points-svc/internal/service/workers/reopener" "github.com/rarimo/rarime-points-svc/internal/service/workers/sbtcheck" "gitlab.com/distributed_lab/kit/kv" @@ -57,6 +58,7 @@ func Run(args []string) bool { run(func(context.Context, config.Config) { sbtcheck.Run(ctx, cfg) }) run(service.Run) run(reopener.Run) + run(expirywatch.Run) case migrateUpCmd.FullCommand(): err = MigrateUp(cfg) case migrateDownCmd.FullCommand(): diff --git a/internal/data/balances.go b/internal/data/balances.go index e337610..538f2aa 100644 --- a/internal/data/balances.go +++ b/internal/data/balances.go @@ -12,6 +12,8 @@ type Balance struct { Amount int64 `db:"amount"` CreatedAt int32 `db:"created_at"` UpdatedAt int32 `db:"updated_at"` + ReferralID string `db:"referral_id"` + ReferredBy sql.NullString `db:"referred_by"` PassportHash sql.NullString `db:"passport_hash"` PassportExpires sql.NullTime `db:"passport_expires"` Rank *int `db:"rank"` @@ -19,14 +21,15 @@ type Balance struct { type BalancesQ interface { New() BalancesQ - Insert(did string) error + Insert(Balance) error UpdateAmountBy(points int64) error SetPassport(hash string, exp time.Time) error Page(*pgdb.OffsetPageParams) BalancesQ Select() ([]Balance, error) Get() (*Balance, error) - WithRank() BalancesQ + WithRank() BalancesQ FilterByDID(string) BalancesQ + FilterByReferralID(string) BalancesQ } diff --git a/internal/data/events.go b/internal/data/events.go index 091c903..94f0b9d 100644 --- a/internal/data/events.go +++ b/internal/data/events.go @@ -39,6 +39,7 @@ type EventsQ interface { New() EventsQ Insert(...Event) error Update(status EventStatus, meta json.RawMessage, points *int64) (*Event, error) + Delete() (rowsAffected int64, err error) Transaction(func() error) error Page(*pgdb.CursorPageParams) EventsQ diff --git a/internal/data/evtypes/config.go b/internal/data/evtypes/config.go index 6da8f82..9e7d497 100644 --- a/internal/data/evtypes/config.go +++ b/internal/data/evtypes/config.go @@ -2,9 +2,7 @@ package evtypes import ( "fmt" - "time" - "github.com/rarimo/rarime-points-svc/resources" "gitlab.com/distributed_lab/figure/v3" "gitlab.com/distributed_lab/kit/comfig" "gitlab.com/distributed_lab/kit/kv" @@ -26,15 +24,7 @@ func NewConfig(getter kv.Getter) EventTypeser { func (c *config) EventTypes() Types { return c.once.Do(func() interface{} { var raw struct { - Types []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"` - } `fig:"types,required"` + Types []EventConfig `fig:"types,required"` } err := figure.Out(&raw). @@ -44,34 +34,21 @@ func (c *config) EventTypes() Types { panic(fmt.Errorf("failed to figure out event_types: %s", err)) } - inner := make(map[string]resources.EventStaticMeta, len(raw.Types)) + m := make(map[string]EventConfig, len(raw.Types)) for _, t := range raw.Types { if !checkFreqValue(t.Frequency) { panic(fmt.Errorf("invalid frequency: %s", t.Frequency)) } - - inner[t.Name] = resources.EventStaticMeta{ - Name: t.Name, - Description: t.Description, - Reward: t.Reward, - Title: t.Title, - Frequency: t.Frequency.String(), - ExpiresAt: t.ExpiresAt, - NoAutoOpen: t.NoAutoOpen, - } - } - - if _, ok := inner[TypeGetPoH]; !ok { - panic(fmt.Errorf("event_types: missing %s entry", TypeGetPoH)) + m[t.Name] = t } - return Types{inner} + return Types{m, raw.Types} }).(Types) } func checkFreqValue(f Frequency) bool { switch f { - case OneTime, Daily, Weekly, Unlimited, Custom: + case OneTime, Daily, Weekly, Unlimited: return true } return false diff --git a/internal/data/evtypes/filters.go b/internal/data/evtypes/filters.go new file mode 100644 index 0000000..615bbb0 --- /dev/null +++ b/internal/data/evtypes/filters.go @@ -0,0 +1,34 @@ +package evtypes + +import ( + "time" +) + +type filter func(EventConfig) bool + +func FilterExpired(ev EventConfig) bool { + return ev.ExpiresAt != nil && ev.ExpiresAt.Before(time.Now().UTC()) +} + +func FilterInactive(ev EventConfig) bool { + return ev.Disabled || FilterExpired(ev) +} + +func FilterNotOpenable(ev EventConfig) bool { + return FilterInactive(ev) || ev.NoAutoOpen +} + +func FilterByFrequency(f Frequency) func(EventConfig) bool { + return func(ev EventConfig) bool { + return ev.Frequency != f + } +} + +func isFiltered(ev EventConfig, filters ...filter) bool { + for _, f := range filters { + if f(ev) { + return true + } + } + return false +} diff --git a/internal/data/evtypes/main.go b/internal/data/evtypes/main.go index d58af0a..319d4df 100644 --- a/internal/data/evtypes/main.go +++ b/internal/data/evtypes/main.go @@ -18,92 +18,103 @@ const ( Daily Frequency = "daily" Weekly Frequency = "weekly" Unlimited Frequency = "unlimited" - Custom Frequency = "custom" ) const ( - TypeGetPoH = "get_poh" - TypeFreeWeekly = "free_weekly" + TypeGetPoH = "get_poh" + TypeFreeWeekly = "free_weekly" + TypeBeReferred = "be_referred" + TypeReferralSpecific = "referral_specific" ) -type Types struct { - inner map[string]resources.EventStaticMeta +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"` } -func (t Types) Get(name string) *resources.EventStaticMeta { - if t.inner == nil { - panic("event types are not correctly initialized") +func (e EventConfig) Resource() resources.EventStaticMeta { + return resources.EventStaticMeta{ + Name: e.Name, + Description: e.Description, + Reward: e.Reward, + Title: e.Title, + Frequency: e.Frequency.String(), + ExpiresAt: e.ExpiresAt, } +} - v, ok := t.inner[name] - if !ok { +type Types struct { + m map[string]EventConfig + list []EventConfig +} + +func (t Types) Get(name string, filters ...filter) *EventConfig { + t.ensureInitialized() + v, ok := t.m[name] + if !ok || isFiltered(v, filters...) { return nil } return &v } -func (t Types) PrepareOpenEvents(userDID string) []data.Event { - evTypes := t.List() - events := make([]data.Event, len(evTypes)) - - for i, et := range evTypes { - events[i] = data.Event{ - UserDID: userDID, - Type: et.Name, - Status: data.EventOpen, - } - - if et.Name == TypeFreeWeekly { - events[i].Status = data.EventFulfilled +func (t Types) List(filters ...filter) []EventConfig { + t.ensureInitialized() + res := make([]EventConfig, 0, len(t.list)) + for _, v := range t.list { + if isFiltered(v, filters...) { + continue } + res = append(res, v) } - - return events + return res } -// List returns non-expired and auto-opening event types -func (t Types) List() []resources.EventStaticMeta { - if t.inner == nil { - panic("event types are not correctly initialized") - } - - res := make([]resources.EventStaticMeta, 0, len(t.inner)) - for _, v := range t.inner { - if v.NoAutoOpen || isExpiredEvent(v) { +func (t Types) Names(filters ...filter) []string { + t.ensureInitialized() + res := make([]string, 0, len(t.list)) + for _, v := range t.list { + if isFiltered(v, filters...) { continue } - res = append(res, v) + res = append(res, v.Name) } - return res } -func (t Types) NamesByFrequency(f Frequency) []string { - if t.inner == nil { - panic("event types are not correctly initialized") - } +func (t Types) PrepareEvents(userDID string, filters ...filter) []data.Event { + t.ensureInitialized() + const extraCap = 1 // in case we append to the resulting slice outside the function + events := make([]data.Event, 0, len(t.list)+extraCap) - res := make([]string, 0, len(t.inner)) - for _, v := range t.inner { - if v.Frequency != f.String() || isExpiredEvent(v) { + for _, et := range t.list { + if isFiltered(et, filters...) { continue } - res = append(res, v.Name) - } - return res -} + status := data.EventOpen + if et.Name == TypeFreeWeekly { + status = data.EventFulfilled + } -func (t Types) IsExpired(name string) bool { - evType := t.Get(name) - if evType == nil { - return false + events = append(events, data.Event{ + UserDID: userDID, + Type: et.Name, + Status: status, + }) } - return isExpiredEvent(*evType) + return events } -func isExpiredEvent(ev resources.EventStaticMeta) bool { - return ev.ExpiresAt != nil && ev.ExpiresAt.Before(time.Now().UTC()) +func (t Types) ensureInitialized() { + if t.m == nil || t.list == nil { + panic("event types are not correctly initialized") + } } diff --git a/internal/data/pg/balances.go b/internal/data/pg/balances.go index 9390756..d183152 100644 --- a/internal/data/pg/balances.go +++ b/internal/data/pg/balances.go @@ -31,11 +31,16 @@ func (q *balances) New() data.BalancesQ { return NewBalances(q.db.Clone()) } -func (q *balances) Insert(did string) error { - stmt := squirrel.Insert(balancesTable).Columns("did").Values(did) +func (q *balances) Insert(bal data.Balance) error { + stmt := squirrel.Insert(balancesTable).SetMap(map[string]interface{}{ + "did": bal.DID, + "amount": bal.Amount, + "referral_id": bal.ReferralID, + "referred_by": bal.ReferredBy, + }) if err := q.db.Exec(stmt); err != nil { - return fmt.Errorf("insert balance for did %s: %w", did, err) + return fmt.Errorf("insert balance %+v: %w", bal, err) } return nil @@ -97,7 +102,15 @@ func (q *balances) WithRank() data.BalancesQ { } func (q *balances) FilterByDID(did string) data.BalancesQ { - q.selector = q.selector.Where(squirrel.Eq{"did": did}) - q.updater = q.updater.Where(squirrel.Eq{"did": did}) + return q.applyCondition(squirrel.Eq{"did": did}) +} + +func (q *balances) FilterByReferralID(referralID string) data.BalancesQ { + return q.applyCondition(squirrel.Eq{"referral_id": referralID}) +} + +func (q *balances) applyCondition(cond squirrel.Eq) data.BalancesQ { + q.selector = q.selector.Where(cond) + q.updater = q.updater.Where(cond) return q } diff --git a/internal/data/pg/events.go b/internal/data/pg/events.go index 15dc3e4..0c2d58c 100644 --- a/internal/data/pg/events.go +++ b/internal/data/pg/events.go @@ -18,6 +18,7 @@ type events struct { db *pgdb.DB selector squirrel.SelectBuilder updater squirrel.UpdateBuilder + deleter squirrel.DeleteBuilder counter squirrel.SelectBuilder reopenable squirrel.SelectBuilder } @@ -27,6 +28,7 @@ func NewEvents(db *pgdb.DB) data.EventsQ { db: db, selector: squirrel.Select("*").From(eventsTable), updater: squirrel.Update(eventsTable), + deleter: squirrel.Delete(eventsTable), counter: squirrel.Select("count(id) AS count").From(eventsTable), reopenable: squirrel.Select("user_did", "type").Distinct().From(eventsTable + " e1"), } @@ -79,6 +81,20 @@ func (q *events) Update(status data.EventStatus, meta json.RawMessage, points *i return &res, nil } +func (q *events) Delete() (int64, error) { + res, err := q.db.ExecWithResult(q.deleter) + if err != nil { + return 0, fmt.Errorf("delete events: %w", err) + } + + rows, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count rows affected: %w", err) + } + + return rows, nil +} + func (q *events) Transaction(f func() error) error { return q.db.Transaction(f) } @@ -195,6 +211,7 @@ func (q *events) FilterByUpdatedAtBefore(unix int64) data.EventsQ { func (q *events) applyCondition(cond squirrel.Sqlizer) data.EventsQ { q.selector = q.selector.Where(cond) q.updater = q.updater.Where(cond) + q.deleter = q.deleter.Where(cond) q.counter = q.counter.Where(cond) q.reopenable = q.reopenable.Where(cond) return q diff --git a/internal/service/handlers/claim_event.go b/internal/service/handlers/claim_event.go index 95d3448..4b6bb7c 100644 --- a/internal/service/handlers/claim_event.go +++ b/internal/service/handlers/claim_event.go @@ -41,12 +41,17 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { return } - evType := EventTypes(r).Get(event.Type) + evType := EventTypes(r).Get(event.Type) // expired events can be claimed if evType == nil { Log(r).Error("Wrong event type is stored in DB: might be bad event config") ape.RenderErr(w, problems.InternalError()) return } + if evType.Disabled { + Log(r).Infof("Event type %s is disabled, while user has tried to claim", evType.Name) + ape.RenderErr(w, problems.Forbidden()) + return + } event, err = claimEventWithPoints(*event, evType.Reward, r) if err != nil { @@ -63,7 +68,7 @@ func ClaimEvent(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newClaimEventResponse(*event, *evType, *balance)) + ape.Render(w, newClaimEventResponse(*event, evType.Resource(), *balance)) } // requires: event exist diff --git a/internal/service/handlers/create_balance.go b/internal/service/handlers/create_balance.go index 323fdda..b2fd500 100644 --- a/internal/service/handlers/create_balance.go +++ b/internal/service/handlers/create_balance.go @@ -1,10 +1,14 @@ package handlers import ( + "database/sql" "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/referralid" "github.com/rarimo/rarime-points-svc/internal/service/requests" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" @@ -37,16 +41,52 @@ func CreateBalance(w http.ResponseWriter, r *http.Request) { return } + var referredBy sql.NullString + if attr := req.Data.Attributes; attr != nil { + referrer, err := BalancesQ(r).FilterByReferralID(attr.ReferredBy).Get() + if err != nil { + Log(r).WithError(err).Error("Failed to check referrer existence") + ape.RenderErr(w, problems.InternalError()) + return + } + if referrer == nil { + Log(r).Debugf("Referrer not found for referral_id=%s", attr.ReferredBy) + ape.RenderErr(w, problems.NotFound()) + return + } + referredBy = sql.NullString{String: attr.ReferredBy, Valid: true} + } + + events := EventTypes(r).PrepareEvents(did, evtypes.FilterNotOpenable) + if referredBy.Valid { + evType := EventTypes(r).Get(evtypes.TypeBeReferred, evtypes.FilterInactive) + if evType != nil { + events = append(events, data.Event{ + UserDID: did, + Type: evtypes.TypeBeReferred, + Status: data.EventFulfilled, + }) + } else { + Log(r).Debug("Referral event is disabled or expired, skipping it") + } + } + err = EventsQ(r).Transaction(func() error { - if err = BalancesQ(r).Insert(did); err != nil { + err = BalancesQ(r).Insert(data.Balance{ + DID: did, + ReferralID: referralid.New(did), + ReferredBy: referredBy, + }) + if err != nil { return fmt.Errorf("add balance: %w", err) } - err = EventsQ(r).Insert(EventTypes(r).PrepareOpenEvents(balance.DID)...) - if err != nil { + + if err = EventsQ(r).Insert(events...); err != nil { return fmt.Errorf("add open events: %w", err) } return nil }) + if err != nil { Log(r).WithError(err).Error("Failed to add balance with open events") ape.RenderErr(w, problems.InternalError()) diff --git a/internal/service/handlers/get_balance.go b/internal/service/handlers/get_balance.go index 34b8322..9c93249 100644 --- a/internal/service/handlers/get_balance.go +++ b/internal/service/handlers/get_balance.go @@ -46,6 +46,7 @@ func newBalanceModel(balance data.Balance) resources.Balance { }, Attributes: resources.BalanceAttributes{ Amount: balance.Amount, + ReferralId: balance.ReferralID, IsVerified: balance.PassportHash.Valid, CreatedAt: balance.CreatedAt, UpdatedAt: balance.UpdatedAt, diff --git a/internal/service/handlers/list_events.go b/internal/service/handlers/list_events.go index bf7bce2..28b0ee9 100644 --- a/internal/service/handlers/list_events.go +++ b/internal/service/handlers/list_events.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "errors" "net/http" "github.com/rarimo/auth-svc/pkg/auth" @@ -10,7 +11,6 @@ import ( "github.com/rarimo/rarime-points-svc/resources" "gitlab.com/distributed_lab/ape" "gitlab.com/distributed_lab/ape/problems" - "gitlab.com/distributed_lab/logan/v3/errors" ) func ListEvents(w http.ResponseWriter, r *http.Request) { @@ -77,11 +77,12 @@ func getOrderedEventsMeta(events []data.Event, r *http.Request) ([]resources.Eve res := make([]resources.EventStaticMeta, len(events)) for i, event := range events { + // even if event type was disabled, we should return it from history evType := EventTypes(r).Get(event.Type) if evType == nil { return nil, errors.New("wrong event type is stored in DB: might be bad event config") } - res[i] = *evType + res[i] = evType.Resource() } return res, nil diff --git a/internal/service/referralid/main.go b/internal/service/referralid/main.go new file mode 100644 index 0000000..82c4c7c --- /dev/null +++ b/internal/service/referralid/main.go @@ -0,0 +1,23 @@ +package referralid + +import ( + "crypto/sha256" + "math/big" +) + +const bytesCount = 6 + +// New returns a base62 of the first 6 bytes of SHA-256 of the input +func New(s string) string { + hash := sha256.New() + hash.Write([]byte(s)) + first := hash.Sum(nil)[:bytesCount] + return base62Encode(first) +} + +// using alphabet: [0-9a-zA-Z] +func base62Encode(b []byte) string { + i := new(big.Int) + i.SetBytes(b) + return i.Text(62) +} diff --git a/internal/service/referralid/main_test.go b/internal/service/referralid/main_test.go new file mode 100644 index 0000000..27e23cb --- /dev/null +++ b/internal/service/referralid/main_test.go @@ -0,0 +1,29 @@ +package referralid + +import "testing" + +func TestNew(t *testing.T) { + tests := []struct { + name string + s string + want string + }{ + { + name: "Valid DID", + s: "did:iden3:readonly:tUDjWxnVJNi7t3FudukqrUcNwF5KVGoWgim5pp2jV", + want: "zgsScguZ", + }, + { + name: "Arbitrary string", + s: "any string $@", + want: "Etv79RQ0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := New(tt.s); got != tt.want { + t.Errorf("New() = %s, want %s", got, tt.want) + } + }) + } +} diff --git a/internal/service/requests/create_balance.go b/internal/service/requests/create_balance.go index 04c44a8..132cbf0 100644 --- a/internal/service/requests/create_balance.go +++ b/internal/service/requests/create_balance.go @@ -9,19 +9,19 @@ import ( "github.com/rarimo/rarime-points-svc/resources" ) -func NewCreateBalance(r *http.Request) (req resources.Relation, err error) { +func NewCreateBalance(r *http.Request) (req resources.CreateBalanceRequest, err error) { if err = json.NewDecoder(r.Body).Decode(&req); err != nil { err = fmt.Errorf("decode request body: %w", err) return } - if req.Data == nil { - err = validation.Errors{"data": validation.ErrRequired} - return - } - - return req, validation.Errors{ + errs := validation.Errors{ "data/id": validation.Validate(req.Data.ID, validation.Required), "data/type": validation.Validate(req.Data.Type, validation.Required, validation.In(resources.CREATE_BALANCE)), - }.Filter() + } + if attr := req.Data.Attributes; attr != nil { + errs["data/attributes/referred_by"] = validation.Validate(attr.ReferredBy, validation.Required) + } + + return req, errs.Filter() } diff --git a/internal/service/workers/reopener/log.go b/internal/service/workers/cron/log.go similarity index 97% rename from internal/service/workers/reopener/log.go rename to internal/service/workers/cron/log.go index 30e2c50..7f3cf26 100644 --- a/internal/service/workers/reopener/log.go +++ b/internal/service/workers/cron/log.go @@ -1,4 +1,4 @@ -package reopener +package cron import ( "gitlab.com/distributed_lab/logan/v3" diff --git a/internal/service/workers/cron/main.go b/internal/service/workers/cron/main.go new file mode 100644 index 0000000..1ea2006 --- /dev/null +++ b/internal/service/workers/cron/main.go @@ -0,0 +1,109 @@ +package cron + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "gitlab.com/distributed_lab/logan/v3" +) + +// how many services should call Start to do the actual start +const countOfServices = 2 + +var sin = struct { + scheduler gocron.Scheduler + mu sync.Mutex + init bool + counter int + log *logan.Entry +}{} + +func Init(log *logan.Entry) { + sin.mu.Lock() + defer sin.mu.Unlock() + + if sin.init { + return + } + + var err error + sin.scheduler, err = gocron.NewScheduler( + gocron.WithLocation(time.UTC), + gocron.WithLogger(newLogger(log)), + ) + if err != nil { + panic(fmt.Errorf("failed to initialize scheduler: %w", err)) + } + sin.log = log.WithField("who", "cron-scheduler") + sin.init = true +} + +func NewJob(jobDef gocron.JobDefinition, task gocron.Task, opts ...gocron.JobOption) (gocron.Job, error) { + sin.mu.Lock() + defer sin.mu.Unlock() + + if !sin.init { + panic("scheduler not initialized") + } + + job, err := sin.scheduler.NewJob(jobDef, task, opts...) + if err != nil { + return nil, fmt.Errorf("add new job: %w", err) + } + + return job, nil +} + +// Start must be called several times by asynchronous services to start the scheduler only once. +func Start(ctx context.Context) { + sin.mu.Lock() + defer sin.mu.Unlock() + + if !sin.init { + panic("scheduler not initialized") + } + + sin.counter++ + if sin.counter < countOfServices { + sin.log.Debugf("Waiting for %d services to start", countOfServices-sin.counter) + return + } + + sin.scheduler.Start() + logJobs() + <-ctx.Done() // all cron jobs are shut down when ctx is canceled + + if err := sin.scheduler.Shutdown(); err != nil { + sin.log.WithError(err).Error("Scheduler shutdown failed") + return + } + sin.log.Info("Scheduler shutdown succeeded") +} + +func logJobs() { + // mutex lock must be already acquired in the caller + jobs := sin.scheduler.Jobs() + logged := make([]string, 0, len(jobs)) + + for _, job := range jobs { + nextRun, err := job.NextRun() + if err != nil { + sin.log.WithError(err). + Errorf("Failed to get next run time: name=%s uuid=%s", job.Name(), job.ID()) + continue + } + + logged = append(logged, fmt.Sprintf("(name=%s next_run=%s)", + job.Name(), nextRun.Format(time.RFC3339))) + } + + if len(logged) == 0 { + sin.log.Warn("No jobs successfully scheduled") + } + + sin.log.Infof("Scheduled jobs: %s", strings.Join(logged, ", ")) +} diff --git a/internal/service/workers/expirywatch/main.go b/internal/service/workers/expirywatch/main.go new file mode 100644 index 0000000..f1a6229 --- /dev/null +++ b/internal/service/workers/expirywatch/main.go @@ -0,0 +1,43 @@ +package expirywatch + +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/evtypes" + "github.com/rarimo/rarime-points-svc/internal/service/workers/cron" +) + +const retryPeriod = 1 * time.Minute + +func Run(ctx context.Context, cfg config.Config) { + w := newWatcher(cfg) + if err := w.initialRun(); err != nil { + panic(fmt.Errorf("expiry-watcher: initial run failed: %w", err)) + } + + cron.Init(cfg.Log()) + expirable := w.types.List(func(ev evtypes.EventConfig) bool { + return ev.Disabled || ev.ExpiresAt == nil || evtypes.FilterExpired(ev) + }) + + for _, ev := range expirable { + if ev.ExpiresAt.Before(time.Now().UTC()) { + continue // although we filtered expired, ensure extra safety due to possible delay + } + + _, err := cron.NewJob( + gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(*ev.ExpiresAt)), + gocron.NewTask(w.job, ctx, ev.Name), + gocron.WithName(fmt.Sprintf("expiry-watch[%s]", ev.Name)), + ) + if err != nil { + panic(fmt.Errorf("failed to initialize job [event_type=%+v]: %w", ev, err)) + } + } + + cron.Start(ctx) +} diff --git a/internal/service/workers/expirywatch/watcher.go b/internal/service/workers/expirywatch/watcher.go new file mode 100644 index 0000000..4784572 --- /dev/null +++ b/internal/service/workers/expirywatch/watcher.go @@ -0,0 +1,62 @@ +package expirywatch + +import ( + "context" + "fmt" + + "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" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/running" +) + +type watcher struct { + q data.EventsQ + types evtypes.Types + log *logan.Entry +} + +func newWatcher(cfg config.Config) *watcher { + return &watcher{ + q: pg.NewEvents(cfg.DB().Clone()), + types: cfg.EventTypes(), + log: cfg.Log().WithField("who", "expiry-watch"), + } +} + +func (w *watcher) initialRun() error { + expired := w.types.Names(func(ev evtypes.EventConfig) bool { + return ev.Disabled || !evtypes.FilterExpired(ev) + }) + + if len(expired) == 0 { + w.log.Debug("No events have expired") + return nil + } + + return w.cleanOpen(expired...) +} + +func (w *watcher) cleanOpen(types ...string) error { + deleted, err := w.q.FilterByType(types...).FilterByStatus(data.EventOpen).Delete() + if err != nil { + return fmt.Errorf("clean expired open events [expired=%v]: %w", types, err) + } + + w.log.Infof("Deleted %d expired open events", deleted) + return nil +} + +func (w *watcher) job(ctx context.Context, evType string) { + name := fmt.Sprintf("expiry-watch[%s]", evType) + log := w.log.WithField("who", name) + + running.WithThreshold(ctx, log, name, func(context.Context) (bool, error) { + if err := w.cleanOpen(evType); err != nil { + return false, fmt.Errorf("clean open events: %w", err) + } + return true, nil + }, retryPeriod, retryPeriod, 12) +} diff --git a/internal/service/workers/reopener/init.go b/internal/service/workers/reopener/init.go index 23b0805..763d2c7 100644 --- a/internal/service/workers/reopener/init.go +++ b/internal/service/workers/reopener/init.go @@ -70,7 +70,7 @@ func (c *initCollector) collect() ([]data.ReopenableEvent, error) { } func (c *initCollector) selectReopenable(freq evtypes.Frequency, before int64) ([]data.ReopenableEvent, error) { - types := c.types.NamesByFrequency(freq) + types := c.types.Names(evtypes.FilterByFrequency(freq), evtypes.FilterInactive) res, err := c.q.New().FilterByType(types...). FilterByUpdatedAtBefore(before). @@ -95,22 +95,14 @@ func (c *initCollector) selectReopenable(freq evtypes.Frequency, before int64) ( } func (c *initCollector) selectAbsent() ([]data.ReopenableEvent, error) { - typesAll := c.types.List() - typeNames := make([]string, len(typesAll)) + types := c.types.Names(evtypes.FilterNotOpenable) - for i, t := range typesAll { - if t.NoAutoOpen { - continue - } - typeNames[i] = t.Name - } - - res, err := c.q.New().SelectAbsentTypes(typeNames...) + res, err := c.q.New().SelectAbsentTypes(types...) if err != nil { - return nil, fmt.Errorf("select events with absent types [types=%v]: %w", typeNames, err) + return nil, fmt.Errorf("select events with absent types [types=%v]: %w", types, err) } - log := c.log.WithField("types", typeNames) + log := c.log.WithField("types", types) if len(res) == 0 { log.Debug("No new event types found to open for new users") return nil, nil diff --git a/internal/service/workers/reopener/main.go b/internal/service/workers/reopener/main.go index 520f90f..ee7b530 100644 --- a/internal/service/workers/reopener/main.go +++ b/internal/service/workers/reopener/main.go @@ -9,108 +9,40 @@ import ( "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" - "gitlab.com/distributed_lab/logan/v3" - "gitlab.com/distributed_lab/running" + "github.com/rarimo/rarime-points-svc/internal/service/workers/cron" ) const retryPeriod = 5 * time.Minute -type worker struct { - name string - freq evtypes.Frequency - q data.EventsQ - types evtypes.Types - log *logan.Entry -} - func Run(ctx context.Context, cfg config.Config) { if err := initialRun(cfg); err != nil { - panic(fmt.Errorf("initial run failed: %w", err)) + panic(fmt.Errorf("reopener: initial run failed: %w", err)) } - scheduler, err := gocron.NewScheduler( - gocron.WithLocation(time.UTC), - gocron.WithLogger(newLogger(cfg.Log())), - ) - if err != nil { - panic(fmt.Errorf("failed to initialize scheduler: %w", err)) - } + cron.Init(cfg.Log()) + atDayStart := gocron.NewAtTimes(gocron.NewAtTime(0, 0, 0)) - atUTC := gocron.NewAtTimes(gocron.NewAtTime(0, 0, 0)) - _, err = scheduler.NewJob( - gocron.DailyJob(1, atUTC), - gocron.NewTask(newWorker(cfg, evtypes.Daily).job, ctx), + daily := newWorker(cfg, evtypes.Daily) + _, err := cron.NewJob( + gocron.DailyJob(1, atDayStart), + gocron.NewTask(daily.job, ctx), + gocron.WithName(daily.name), ) if err != nil { - panic(fmt.Errorf("failed to initialize daily job: %w", err)) + panic(fmt.Errorf("reopener: failed to initialize daily job: %w", err)) } - _, err = scheduler.NewJob( - gocron.WeeklyJob(1, gocron.NewWeekdays(time.Monday), atUTC), - gocron.NewTask(newWorker(cfg, evtypes.Weekly).job, ctx), - ) - if err != nil { - panic(fmt.Errorf("failed to initialize weekly job: %w", err)) - } - - scheduler.Start() - <-ctx.Done() - if err = scheduler.Shutdown(); err != nil { - cfg.Log().WithError(err).Error("Scheduler shutdown failed") - return - } - cfg.Log().Info("Scheduler shutdown succeeded") -} -func newWorker(cfg config.Config, freq evtypes.Frequency) *worker { - name := fmt.Sprintf("reopener[%s]", freq.String()) - return &worker{ - name: name, - freq: freq, - q: pg.NewEvents(cfg.DB().Clone()), - types: cfg.EventTypes(), - log: cfg.Log().WithField("who", name), - } -} - -func (w *worker) job(ctx context.Context) { - // types might expire, so it's required to get them before each run - types := w.types.NamesByFrequency(w.freq) - if len(types) == 0 { - w.log.Info("No events to reopen: all types expired or no types with frequency exist") - return - } - w.log.WithField("event_types", types). - Debug("Reopening claimed events") - - running.WithThreshold(ctx, w.log, w.name, func(context.Context) (bool, error) { - if err := w.reopenEvents(types); err != nil { - return false, fmt.Errorf("reopen events: %w", err) - } - return true, nil - }, retryPeriod, retryPeriod, 12) -} - -func (w *worker) reopenEvents(types []string) error { - log := w.log.WithField("event_types", types) - - events, err := w.q.New().FilterByType(types...).SelectReopenable() - if err != nil { - return fmt.Errorf("select reopenable events [types=%v]: %w", types, err) - } - if len(events) == 0 { - log.Info("No events to reopen: no claimed events found for provided types") - return nil - } - log.Infof("%d (DID, type) pairs to reopen: %v", len(events), events) - - err = w.q.New().Insert(prepareForReopening(events)...) + weekly := newWorker(cfg, evtypes.Weekly) + _, err = cron.NewJob( + gocron.WeeklyJob(1, gocron.NewWeekdays(time.Monday), atDayStart), + gocron.NewTask(weekly.job, ctx), + gocron.WithName(weekly.name), + ) if err != nil { - return fmt.Errorf("insert events for reopening: %w", err) + panic(fmt.Errorf("reopener: failed to initialize weekly job: %w", err)) } - w.log.Infof("Reopened %d events", len(events)) - return nil + cron.Start(ctx) } func prepareForReopening(events []data.ReopenableEvent) []data.Event { diff --git a/internal/service/workers/reopener/worker.go b/internal/service/workers/reopener/worker.go new file mode 100644 index 0000000..e0b4285 --- /dev/null +++ b/internal/service/workers/reopener/worker.go @@ -0,0 +1,71 @@ +package reopener + +import ( + "context" + "fmt" + + "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" + "gitlab.com/distributed_lab/logan/v3" + "gitlab.com/distributed_lab/running" +) + +type worker struct { + name string + freq evtypes.Frequency + q data.EventsQ + types evtypes.Types + log *logan.Entry +} + +func newWorker(cfg config.Config, freq evtypes.Frequency) *worker { + name := fmt.Sprintf("reopener[%s]", freq.String()) + return &worker{ + name: name, + freq: freq, + q: pg.NewEvents(cfg.DB().Clone()), + types: cfg.EventTypes(), + log: cfg.Log().WithField("who", name), + } +} + +func (w *worker) job(ctx context.Context) { + // types might expire, so it's required to get them before each run + types := w.types.Names(evtypes.FilterByFrequency(w.freq), evtypes.FilterInactive) + if len(types) == 0 { + w.log.Info("No events to reopen: all types expired or no types with frequency exist") + return + } + w.log.WithField("event_types", types).Debug("Reopening claimed events") + + running.WithThreshold(ctx, w.log, w.name, func(context.Context) (bool, error) { + if err := w.reopenEvents(types); err != nil { + return false, fmt.Errorf("reopen events: %w", err) + } + return true, nil + }, retryPeriod, retryPeriod, 12) +} + +func (w *worker) reopenEvents(types []string) error { + log := w.log.WithField("event_types", types) + + events, err := w.q.New().FilterByType(types...).SelectReopenable() + if err != nil { + return fmt.Errorf("select reopenable events [types=%v]: %w", types, err) + } + if len(events) == 0 { + log.Info("No events to reopen: no claimed events found for provided types") + return nil + } + log.Infof("%d (DID, type) pairs to reopen: %v", len(events), events) + + err = w.q.New().Insert(prepareForReopening(events)...) + if err != nil { + return fmt.Errorf("insert events for reopening: %w", err) + } + + w.log.Infof("Reopened %d events", len(events)) + return nil +} diff --git a/internal/service/workers/sbtcheck/main.go b/internal/service/workers/sbtcheck/main.go index af24b0f..5d39cb9 100644 --- a/internal/service/workers/sbtcheck/main.go +++ b/internal/service/workers/sbtcheck/main.go @@ -12,6 +12,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/data/pg" + "github.com/rarimo/rarime-points-svc/internal/service/referralid" "github.com/rarimo/rarime-points-svc/internal/service/workers/sbtcheck/verifiers" "gitlab.com/distributed_lab/kit/comfig" "gitlab.com/distributed_lab/kit/pgdb" @@ -21,9 +22,10 @@ import ( type runner struct { network - db *pgdb.DB - types evtypes.Types - log *logan.Entry + db *pgdb.DB + types evtypes.Types + log *logan.Entry + cancel context.CancelFunc } type network struct { @@ -54,11 +56,14 @@ type extConfig interface { func Run(ctx context.Context, cfg extConfig) { log := cfg.Log().WithField("who", "sbt-checker") - if cfg.EventTypes().IsExpired(evtypes.TypeGetPoH) { - log.Warn("PoH event is expired, SBT check will not run") + if cfg.EventTypes().Get(evtypes.TypeGetPoH, evtypes.FilterInactive) == nil { + log.Warn("PoH event is disabled or expired, SBT check will not run") return } + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + var wg sync.WaitGroup for name, net := range cfg.SbtCheck().networks { if net.disabled { @@ -74,11 +79,12 @@ func Run(ctx context.Context, cfg extConfig) { db: cfg.DB(), types: cfg.EventTypes(), log: log.WithField("network", name), + cancel: cancel, } runnerName := fmt.Sprintf("sbt-checker[%s]", net.name) go func() { - running.WithBackOff(ctx, r.log, runnerName, r.subscription, + running.WithBackOff(ctx2, r.log, runnerName, r.subscription, 30*time.Second, 5*time.Second, 30*time.Second) wg.Done() }() @@ -236,9 +242,11 @@ func (r *runner) findPohEvent(did string) (*data.Event, error) { } func (r *runner) fulfillPohEvent(poh data.Event) error { - getPoh := r.types.Get(evtypes.TypeGetPoH) + getPoh := r.types.Get(evtypes.TypeGetPoH, evtypes.FilterExpired) if getPoh == nil { - return fmt.Errorf("event types were not correctly initialized: missing %s", evtypes.TypeGetPoH) + r.log.Warn("PoH event type has expired: stopping SBT checkers for all networks") + r.cancel() // detected by running.WithBackOff + return nil } _, err := r.eventsQ().FilterByID(poh.ID).Update(data.EventFulfilled, nil, &getPoh.Reward) @@ -251,12 +259,15 @@ func (r *runner) fulfillPohEvent(poh data.Event) error { func (r *runner) createBalance(did string) error { return r.eventsQ().Transaction(func() error { - - if err := r.balancesQ().Insert(did); err != nil { + err := r.balancesQ().Insert(data.Balance{ + DID: did, + ReferralID: referralid.New(did), + }) + if err != nil { return fmt.Errorf("insert balance: %w", err) } - err := r.eventsQ().Insert(r.types.PrepareOpenEvents(did)...) + err = r.eventsQ().Insert(r.types.PrepareEvents(did, evtypes.FilterNotOpenable)...) if err != nil { return fmt.Errorf("insert open events: %w", err) } diff --git a/resources/model_balance_attributes.go b/resources/model_balance_attributes.go index 03421da..b8af7bf 100644 --- a/resources/model_balance_attributes.go +++ b/resources/model_balance_attributes.go @@ -13,6 +13,8 @@ type BalanceAttributes struct { IsVerified bool `json:"is_verified"` // Rank of the user in the full leaderboard. Returned only for the single user. Rank *int `json:"rank,omitempty"` + // Referral ID used to build a referral link and send it to friends + ReferralId string `json:"referral_id"` // Unix timestamp of the last points accruing UpdatedAt int32 `json:"updated_at"` } diff --git a/resources/model_create_balance.go b/resources/model_create_balance.go new file mode 100644 index 0000000..f4b76cc --- /dev/null +++ b/resources/model_create_balance.go @@ -0,0 +1,43 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +import "encoding/json" + +type CreateBalance struct { + Key + Attributes *CreateBalanceAttributes `json:"attributes,omitempty"` +} +type CreateBalanceRequest struct { + Data CreateBalance `json:"data"` + Included Included `json:"included"` +} + +type CreateBalanceListRequest struct { + Data []CreateBalance `json:"data"` + Included Included `json:"included"` + Links *Links `json:"links"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +func (r *CreateBalanceListRequest) PutMeta(v interface{}) (err error) { + r.Meta, err = json.Marshal(v) + return err +} + +func (r *CreateBalanceListRequest) GetMeta(out interface{}) error { + return json.Unmarshal(r.Meta, out) +} + +// MustCreateBalance - returns CreateBalance 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) MustCreateBalance(key Key) *CreateBalance { + var createBalance CreateBalance + if c.tryFindEntry(key, &createBalance) { + return &createBalance + } + return nil +} diff --git a/resources/model_create_balance_attributes.go b/resources/model_create_balance_attributes.go new file mode 100644 index 0000000..66313a8 --- /dev/null +++ b/resources/model_create_balance_attributes.go @@ -0,0 +1,10 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +type CreateBalanceAttributes struct { + // ID of the referrer from the link + ReferredBy string `json:"referred_by"` +} diff --git a/resources/model_event_static_meta.go b/resources/model_event_static_meta.go index ac16602..16639e5 100644 --- a/resources/model_event_static_meta.go +++ b/resources/model_event_static_meta.go @@ -15,8 +15,6 @@ type EventStaticMeta struct { Frequency string `json:"frequency"` // Unique event code name Name string `json:"name"` - // If true, the event will not be created with `open` status automatically when user creates the balance. - NoAutoOpen bool `json:"no_auto_open"` // Reward amount in points Reward int64 `json:"reward"` Title string `json:"title"` diff --git a/werf.yaml b/werf.yaml index 5d63e7e..5a5737e 100644 --- a/werf.yaml +++ b/werf.yaml @@ -2,7 +2,7 @@ configVersion: 1 project: "rarime-points-svc" --- image: builder -from: golang:1.21-alpine +from: golang:1.22-alpine docker: WORKDIR: /go/src/github.com/rarimo/rarime-points-svc git: