diff --git a/docs/spec/components/schemas/BonusCode.yaml b/docs/spec/components/schemas/BonusCode.yaml new file mode 100644 index 0000000..677647d --- /dev/null +++ b/docs/spec/components/schemas/BonusCode.yaml @@ -0,0 +1,25 @@ +allOf: + - $ref: '#/components/schemas/BonusCodeKey' + - type: object + x-go-is-request: true + required: + - attributes + properties: + attributes: + type: object + properties: + reward: + type: integer + format: int + description: Reward for this bonus code + default: 10 + example: 10 + usage_count: + type: integer + format: int + description: Specify how many times bonus code can be scaned. Omit if bonus code must have infinity usage count + example: 1 + nullifier: + type: string + description: For creating personal bonus codes + example: "0xabc...123" diff --git a/docs/spec/components/schemas/BonusCodeKey.yaml b/docs/spec/components/schemas/BonusCodeKey.yaml new file mode 100644 index 0000000..a9c659f --- /dev/null +++ b/docs/spec/components/schemas/BonusCodeKey.yaml @@ -0,0 +1,12 @@ +type: object +required: + - id + - type +properties: + id: + type: string + description: Bonus code value + example: "one_time_abcdefg..xyz" + type: + type: string + enum: [ bonus_code ] diff --git a/docs/spec/components/schemas/EventClaimingState.yaml b/docs/spec/components/schemas/EventClaimingState.yaml index ae8892f..2edd2d9 100644 --- a/docs/spec/components/schemas/EventClaimingState.yaml +++ b/docs/spec/components/schemas/EventClaimingState.yaml @@ -7,9 +7,15 @@ allOf: attributes: required: - claimed + - reward type: object properties: claimed: type: bool example: true - description: If passport scan event was automatically claimed \ No newline at end of file + description: If passport scan event was automatically claimed + reward: + type: integer + format: int64 + description: Reward amount in points + example: 50 \ No newline at end of file diff --git a/docs/spec/paths/integrations@geo-points-svc@v2@public@codes.yaml b/docs/spec/paths/integrations@geo-points-svc@v2@public@codes.yaml new file mode 100644 index 0000000..8f79fbf --- /dev/null +++ b/docs/spec/paths/integrations@geo-points-svc@v2@public@codes.yaml @@ -0,0 +1,37 @@ +post: + tags: + - Bonus Codes + summary: Createbonus code + description: Create custom bonus code + operationId: createCode + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/BonusCode' + responses: + 200: + description: Success + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/BonusCode' + 400: + $ref: '#/components/responses/invalidParameter' + 401: + $ref: '#/components/responses/invalidAuth' + 500: + $ref: '#/components/responses/internalError' diff --git a/docs/spec/paths/integrations@geo-points-svc@v2@public@codes@{code}.yaml b/docs/spec/paths/integrations@geo-points-svc@v2@public@codes@{code}.yaml new file mode 100644 index 0000000..625d0fa --- /dev/null +++ b/docs/spec/paths/integrations@geo-points-svc@v2@public@codes@{code}.yaml @@ -0,0 +1,51 @@ +post: + tags: + - Bonus Codes + summary: Send code + description: Send a code and get a reward + operationId: submitCode + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/BonusCodeKey' + responses: + 200: + description: Success + content: + application/vnd.api+json: + schema: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/EventClaimingState' + 400: + $ref: '#/components/responses/invalidParameter' + 401: + $ref: '#/components/responses/invalidAuth' + 403: + description: May be user haven't verified passport + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/Errors' + 404: + $ref: '#/components/responses/notFound' + 409: + description: QR code already submited + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/Errors' + 500: + $ref: '#/components/responses/internalError' diff --git a/internal/assets/migrations/006_one_time_qr_code.sql b/internal/assets/migrations/006_one_time_qr_code.sql new file mode 100644 index 0000000..e5f26df --- /dev/null +++ b/internal/assets/migrations/006_one_time_qr_code.sql @@ -0,0 +1,27 @@ +-- +migrate Up +CREATE OR REPLACE FUNCTION trigger_set_updated_at_ts() RETURNS trigger + LANGUAGE plpgsql +AS $$ BEGIN NEW.updated_at = (NOW() AT TIME ZONE 'utc'); RETURN NEW; END; $$; + +CREATE TABLE IF NOT EXISTS bonus_codes ( + id TEXT PRIMARY KEY, + nullifier TEXT REFERENCES balances (nullifier), + reward BIGINT NOT NULL, + usage_count BIGINT NOT NULL DEFAULT 0, + infinity BOOLEAN NOT NULL DEFAULT FALSE, + + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +DROP TRIGGER IF EXISTS set_updated_at ON bonus_codes; +CREATE TRIGGER set_updated_at + BEFORE UPDATE + ON bonus_codes + FOR EACH ROW +EXECUTE FUNCTION trigger_set_updated_at_ts(); + + +-- +migrate Down +DROP TABLE IF EXISTS bonus_codes; +DROP FUNCTION IF EXISTS trigger_set_updated_at_ts(); diff --git a/internal/data/bonus_codes.go b/internal/data/bonus_codes.go new file mode 100644 index 0000000..fb6433c --- /dev/null +++ b/internal/data/bonus_codes.go @@ -0,0 +1,40 @@ +package data + +import ( + "database/sql" + "time" + + "gitlab.com/distributed_lab/kit/pgdb" +) + +const ( + ColNullifier = "nullifier" + ColUsageCount = "usage_count" + ColInfinity = "infinity" +) + +type BonusCode struct { + ID string `db:"id"` + Nullifier sql.NullString `db:"nullifier"` + Reward int `db:"reward"` + UsageCount int `db:"usage_count"` + Infinity bool `db:"infinity"` + + UpdatedAt time.Time `db:"updated_at"` + CreatedAt time.Time `db:"created_at"` +} + +type BonusCodesQ interface { + New() BonusCodesQ + Insert(...BonusCode) error + Update(map[string]any) error + + Page(*pgdb.OffsetPageParams) BonusCodesQ + + Get() (*BonusCode, error) + Select() ([]BonusCode, error) + Count() (uint64, error) + + FilterByID(...string) BonusCodesQ + FilterByNullifier(...string) BonusCodesQ +} diff --git a/internal/data/events.go b/internal/data/events.go index 827a5d9..c3a3359 100644 --- a/internal/data/events.go +++ b/internal/data/events.go @@ -66,6 +66,7 @@ type EventsQ interface { FilterByType(...string) EventsQ FilterByNotType(types ...string) EventsQ FilterByUpdatedAtBefore(int64) EventsQ + FilterByBonusCode(string) EventsQ FilterTodayEvents(offset int) EventsQ diff --git a/internal/data/evtypes/models/extra.go b/internal/data/evtypes/models/extra.go index c48d00a..69afe6e 100644 --- a/internal/data/evtypes/models/extra.go +++ b/internal/data/evtypes/models/extra.go @@ -23,6 +23,7 @@ const ( TypePollParticipation = "poll_participation" TypeEarlyTest = "early_test" TypeDailyQuestion = "daily_question" + TypeBonusCode = "bonus_code" ) const ( diff --git a/internal/data/pg/bonus_codes.go b/internal/data/pg/bonus_codes.go new file mode 100644 index 0000000..8823933 --- /dev/null +++ b/internal/data/pg/bonus_codes.go @@ -0,0 +1,114 @@ +package pg + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/rarimo/geo-points-svc/internal/data" + "gitlab.com/distributed_lab/kit/pgdb" +) + +const bonusCodesTable = "bonus_codes" + +type bonusCodesQ struct { + db *pgdb.DB + selector squirrel.SelectBuilder + updater squirrel.UpdateBuilder + counter squirrel.SelectBuilder +} + +func NewBonusCodesQ(db *pgdb.DB) data.BonusCodesQ { + return &bonusCodesQ{ + db: db, + selector: squirrel.Select("id", bonusCodesTable+".nullifier AS nullifier", "usage_count", "infinity", "reward").From(bonusCodesTable), + updater: squirrel.Update(bonusCodesTable), + counter: squirrel.Select("COUNT(*) as count").From(bonusCodesTable), + } +} + +func (q *bonusCodesQ) New() data.BonusCodesQ { + return NewBonusCodesQ(q.db) +} + +func (q *bonusCodesQ) Insert(bonusCodes ...data.BonusCode) error { + if len(bonusCodes) == 0 { + return nil + } + + stmt := squirrel.Insert(bonusCodesTable).Columns("id", "nullifier", "reward", "usage_count", "infinity") + for _, bonusCode := range bonusCodes { + stmt = stmt.Values(bonusCode.ID, bonusCode.Nullifier, bonusCode.Reward, bonusCode.UsageCount, bonusCode.Infinity) + } + + if err := q.db.Exec(stmt); err != nil { + return fmt.Errorf("insert bonus codes: %w", err) + } + + return nil +} + +func (q *bonusCodesQ) Update(values map[string]any) error { + + if err := q.db.Exec(q.updater.SetMap(values)); err != nil { + return fmt.Errorf("update bonusCode: %w", err) + } + + return nil +} + +func (q *bonusCodesQ) Select() ([]data.BonusCode, error) { + var res []data.BonusCode + + if err := q.db.Select(&res, q.selector); err != nil { + return nil, fmt.Errorf("select bonusCodes: %w", err) + } + + return res, nil +} + +func (q *bonusCodesQ) Get() (*data.BonusCode, error) { + var res data.BonusCode + + if err := q.db.Get(&res, q.selector); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("get bonusCode: %w", err) + } + + return &res, nil +} + +func (q *bonusCodesQ) Page(page *pgdb.OffsetPageParams) data.BonusCodesQ { + q.selector = page.ApplyTo(q.selector, "updated_at") + return q +} + +func (q *bonusCodesQ) Count() (uint64, error) { + var res struct { + Count uint64 `db:"count"` + } + + if err := q.db.Get(&res, q.counter); err != nil { + return 0, fmt.Errorf("count bonusCodes: %w", err) + } + + return res.Count, nil +} + +func (q *bonusCodesQ) FilterByNullifier(nullifiers ...string) data.BonusCodesQ { + return q.applyCondition(squirrel.Eq{fmt.Sprintf("%s.nullifier", bonusCodesTable): nullifiers}) +} + +func (q *bonusCodesQ) FilterByID(ids ...string) data.BonusCodesQ { + return q.applyCondition(squirrel.Eq{fmt.Sprintf("%s.id", bonusCodesTable): ids}) +} + +func (q *bonusCodesQ) applyCondition(cond squirrel.Sqlizer) data.BonusCodesQ { + q.selector = q.selector.Where(cond) + q.updater = q.updater.Where(cond) + q.counter = q.counter.Where(cond) + return q +} diff --git a/internal/data/pg/events.go b/internal/data/pg/events.go index 1921f57..becde53 100644 --- a/internal/data/pg/events.go +++ b/internal/data/pg/events.go @@ -260,6 +260,10 @@ func (q *events) FilterByQuestionID(id int) data.EventsQ { return q.applyCondition(squirrel.Eq{"meta->>'question_id'": id}) } +func (q *events) FilterByBonusCode(bonusCode string) data.EventsQ { + return q.applyCondition(squirrel.Eq{"meta->>'bonus_code'": bonusCode}) +} + func (q *events) FilterInactiveNotClaimed(types ...string) data.EventsQ { if len(types) == 0 { return q diff --git a/internal/service/handlers/create_bonus_code.go b/internal/service/handlers/create_bonus_code.go new file mode 100644 index 0000000..91d1b55 --- /dev/null +++ b/internal/service/handlers/create_bonus_code.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "net/http" + + "github.com/rarimo/geo-auth-svc/pkg/auth" + "github.com/rarimo/geo-points-svc/internal/data" + "github.com/rarimo/geo-points-svc/internal/service/requests" + "github.com/rarimo/geo-points-svc/resources" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +func CreateBonusCode(w http.ResponseWriter, r *http.Request) { + req, err := requests.NewCreateBonusCode(r) + if err != nil { + ape.RenderErr(w, problems.BadRequest(err)...) + return + } + + if !auth.Authenticates(UserClaims(r), auth.AdminGrant) { + ape.RenderErr(w, problems.Unauthorized()) + return + } + + var ( + _ = req.Data.Attributes.Nullifier + dataUsageCount = req.Data.Attributes.UsageCount + dataReward = req.Data.Attributes.Reward + usageCount = 1 + reward = 10 + ) + + bonusValue := make([]byte, 10) + _, err = rand.Read(bonusValue[:]) + if err != nil { + Log(r).WithError(err).Error("Failed to get rand bytes") + ape.RenderErr(w, problems.InternalError()) + return + } + + if dataUsageCount != nil { + usageCount = *dataUsageCount + } + + if dataReward != nil { + reward = *dataReward + } + + bonus := data.BonusCode{ + ID: "one_time_" + hex.EncodeToString(bonusValue), + Nullifier: sql.NullString{}, + UsageCount: usageCount, + Reward: reward, + } + + if err = BonusCodesQ(r).Insert(bonus); err != nil { + Log(r).WithError(err).Error("Failed to insert bonus code") + ape.RenderErr(w, problems.InternalError()) + return + } + + ape.Render(w, resources.BonusCodeRequest{ + Data: resources.BonusCode{ + Key: resources.Key{ + ID: bonus.ID, + Type: resources.BONUS_CODE, + }, + Attributes: resources.BonusCodeAttributes{ + Reward: &reward, + UsageCount: &usageCount, + }, + }, + }) + +} diff --git a/internal/service/handlers/ctx.go b/internal/service/handlers/ctx.go index 0ccff85..92669f8 100644 --- a/internal/service/handlers/ctx.go +++ b/internal/service/handlers/ctx.go @@ -30,6 +30,7 @@ const ( dailyQuestionsCtxKey dailyQuestionsCfgCtxKey abstractionCtxKey + bonusCodesQCtxKey ) func CtxLog(entry *logan.Entry) func(context.Context) context.Context { @@ -112,6 +113,16 @@ func WithdrawalsQ(r *http.Request) data.WithdrawalsQ { return r.Context().Value(withdrawalsQCtxKey).(data.WithdrawalsQ).New() } +func CtxBonusCodesQ(q data.BonusCodesQ) func(context.Context) context.Context { + return func(ctx context.Context) context.Context { + return context.WithValue(ctx, bonusCodesQCtxKey, q) + } +} + +func BonusCodesQ(r *http.Request) data.BonusCodesQ { + return r.Context().Value(bonusCodesQCtxKey).(data.BonusCodesQ).New() +} + func CtxUserClaims(claim []resources.Claim) func(context.Context) context.Context { return func(ctx context.Context) context.Context { return context.WithValue(ctx, userClaimsCtxKey, claim) diff --git a/internal/service/handlers/fulfill_poll_event.go b/internal/service/handlers/fulfill_poll_event.go index b2bc0f4..3e314a7 100644 --- a/internal/service/handlers/fulfill_poll_event.go +++ b/internal/service/handlers/fulfill_poll_event.go @@ -4,11 +4,12 @@ import ( "encoding/json" "errors" "fmt" - "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/rarimo/geo-auth-svc/pkg/auth" "math/big" "net/http" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/rarimo/geo-auth-svc/pkg/auth" + validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/rarimo/geo-points-svc/internal/config" "github.com/rarimo/geo-points-svc/internal/data" @@ -127,7 +128,7 @@ func FulfillPollEvent(w http.ResponseWriter, r *http.Request) { if !evType.AutoClaim { log.Debug("Event fulfilled due to disabled auto-claim") - ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, false)) + ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, false, 0)) return } @@ -140,5 +141,5 @@ func FulfillPollEvent(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, true)) + ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, true, evType.Reward)) } diff --git a/internal/service/handlers/fulfill_qr_event.go b/internal/service/handlers/fulfill_qr_event.go index 7adc105..d745815 100644 --- a/internal/service/handlers/fulfill_qr_event.go +++ b/internal/service/handlers/fulfill_qr_event.go @@ -73,7 +73,7 @@ func FulfillQREvent(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, false)) + ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, false, 0)) return } @@ -88,5 +88,5 @@ func FulfillQREvent(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, true)) + ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, true, evType.Reward)) } diff --git a/internal/service/handlers/middleware.go b/internal/service/handlers/middleware.go index ffd4646..bbe7176 100644 --- a/internal/service/handlers/middleware.go +++ b/internal/service/handlers/middleware.go @@ -52,6 +52,7 @@ func DBCloneMiddleware(db *pgdb.DB) func(http.Handler) http.Handler { CtxEventTypesQ(pg.NewEventTypes(clone)), CtxDailyQuestionsQ(pg.NewDailyQuestionsQ(clone)), CtxWithdrawalsQ(pg.NewWithdrawals(clone)), + CtxBonusCodesQ(pg.NewBonusCodesQ(clone)), } for _, extender := range extenders { diff --git a/internal/service/handlers/submit_bonus_code.go b/internal/service/handlers/submit_bonus_code.go new file mode 100644 index 0000000..9e7e272 --- /dev/null +++ b/internal/service/handlers/submit_bonus_code.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "fmt" + "net/http" + "regexp" + + "github.com/go-chi/chi" + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rarimo/geo-points-svc/internal/data" + "github.com/rarimo/geo-points-svc/internal/data/evtypes" + "github.com/rarimo/geo-points-svc/internal/data/evtypes/models" + "github.com/rarimo/geo-points-svc/internal/data/pg" + "gitlab.com/distributed_lab/ape" + "gitlab.com/distributed_lab/ape/problems" +) + +var bonusCodeRegexp = regexp.MustCompile("[0-9A-Za-z_]+") + +func SubmitBonusCode(w http.ResponseWriter, r *http.Request) { + bonusCode := chi.URLParam(r, "bonus_code") + if !bonusCodeRegexp.MatchString(bonusCode) { + ape.RenderErr(w, problems.BadRequest(validation.Errors{ + "path": fmt.Errorf("invalid bonus_code format"), + })...) + return + } + + // never panic because of auth validation + nullifier := UserClaims(r)[0].Nullifier + + log := Log(r).WithFields(map[string]any{ + "nullifier": nullifier, + "bonus_code": bonusCode, + }) + + balance, err := BalancesQ(r).FilterDisabled().FilterByNullifier(nullifier).Get() + if err != nil { + log.WithError(err).Error("Failed to get balance") + ape.RenderErr(w, problems.InternalError()) + return + } + if balance == nil { + log.Debug("Balance not found") + ape.RenderErr(w, problems.NotFound()) + return + } + if !balance.IsVerified() { + log.Debug("Balance not verified") + ape.RenderErr(w, problems.Forbidden()) + return + } + + bonus, err := BonusCodesQ(r).FilterByID(bonusCode).Get() + if err != nil { + log.WithError(err).Error("Failed to get bonus code") + ape.RenderErr(w, problems.InternalError()) + return + } + if bonus == nil { + log.Debug("Bonus code absent in db") + ape.RenderErr(w, problems.NotFound()) + return + } + if !bonus.Infinity && bonus.UsageCount <= 0 { + log.Debug("Bonus code usage count exceed") + ape.RenderErr(w, problems.NotFound()) + return + } + + ev, err := EventsQ(r).FilterByNullifier(nullifier).FilterByType(bonusCode).Get() + if err != nil { + log.WithError(err).Error("Failed to get event by type") + ape.RenderErr(w, problems.InternalError()) + return + } + if ev != nil { + log.Debug("User already scan old bonus code") + ape.RenderErr(w, problems.Conflict()) + return + } + + ev, err = EventsQ(r).FilterByNullifier(nullifier).FilterByType(models.TypeBonusCode).FilterByBonusCode(bonusCode).Get() + if err != nil { + log.WithError(err).Error("Failed to get event by bonuscode") + ape.RenderErr(w, problems.InternalError()) + return + } + if ev != nil { + log.Debug("User already scan bonus code") + ape.RenderErr(w, problems.Conflict()) + return + } + + evType := EventTypes(r).Get(models.TypeBonusCode, evtypes.FilterInactive) + if evType == nil { + log.Debug("Event Bonus code absent or inactive") + ape.RenderErr(w, problems.Forbidden()) + return + } + + reward := int64(bonus.Reward) + err = EventsQ(r).Transaction(func() error { + if !evType.AutoClaim { + return EventsQ(r).Insert(data.Event{ + Nullifier: nullifier, + Type: models.TypeBonusCode, + Status: data.EventFulfilled, + PointsAmount: &reward, + Meta: data.Jsonb(fmt.Sprintf(`{"bonus_code": "%s"}`, bonus.ID)), + }) + } + + err = EventsQ(r).Insert(data.Event{ + Nullifier: nullifier, + Type: models.TypeBonusCode, + Status: data.EventClaimed, + PointsAmount: &reward, + Meta: data.Jsonb(fmt.Sprintf(`{"bonus_code": "%s"}`, bonus.ID)), + }) + if err != nil { + return fmt.Errorf("failed to insert event: %w", err) + } + + level, err := DoLevelRefUpgrade(Levels(r), ReferralsQ(r), balance, reward) + if err != nil { + return fmt.Errorf("failed to do lvlup and referrals updates: %w", err) + } + + err = BalancesQ(r).FilterByNullifier(balance.Nullifier).Update(map[string]any{ + data.ColAmount: pg.AddToValue(data.ColAmount, reward), + data.ColLevel: level, + }) + if err != nil { + return fmt.Errorf("update balance amount and level: %w", err) + } + + if !bonus.Infinity { + err = BonusCodesQ(r).FilterByID(bonusCode).Update(map[string]any{ + data.ColUsageCount: bonus.UsageCount - 1, + }) + if err != nil { + return fmt.Errorf("failed to update bonus code: %w", err) + } + } + + return nil + }) + if err != nil { + log.WithError(err).Error("Failed to exec tx") + ape.RenderErr(w, problems.InternalError()) + return + } + + ape.Render(w, newEventClaimingStateResponse(balance.Nullifier, true, reward)) +} diff --git a/internal/service/handlers/verify_external_passport.go b/internal/service/handlers/verify_external_passport.go index 5931e9d..c425dd0 100644 --- a/internal/service/handlers/verify_external_passport.go +++ b/internal/service/handlers/verify_external_passport.go @@ -154,7 +154,7 @@ func VerifyExternalPassport(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(req.Data.ID, event != nil)) + ape.Render(w, newEventClaimingStateResponse(req.Data.ID, event != nil, 0)) } diff --git a/internal/service/handlers/verify_internal_passport.go b/internal/service/handlers/verify_internal_passport.go index 3881930..a4c3231 100644 --- a/internal/service/handlers/verify_internal_passport.go +++ b/internal/service/handlers/verify_internal_passport.go @@ -41,7 +41,6 @@ func VerifyInternalPassport(w http.ResponseWriter, r *http.Request) { "balance.nullifier": req.Data.ID, "balance.internal_aid": internalAID, }) - gotSig = r.Header.Get("Signature") ) @@ -145,7 +144,7 @@ func VerifyInternalPassport(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(req.Data.ID, false)) + ape.Render(w, newEventClaimingStateResponse(req.Data.ID, false, 0)) return } @@ -198,14 +197,15 @@ func VerifyInternalPassport(w http.ResponseWriter, r *http.Request) { return } - ape.Render(w, newEventClaimingStateResponse(req.Data.ID, event != nil)) + ape.Render(w, newEventClaimingStateResponse(req.Data.ID, event != nil, 0)) } -func newEventClaimingStateResponse(id string, isClaimed bool) resources.EventClaimingStateResponse { +func newEventClaimingStateResponse(id string, isClaimed bool, reward int64) resources.EventClaimingStateResponse { var res resources.EventClaimingStateResponse res.Data.ID = id res.Data.Type = resources.EVENT_CLAIMING_STATE res.Data.Attributes.Claimed = isClaimed + res.Data.Attributes.Reward = reward return res } diff --git a/internal/service/requests/create_bonus_code.go b/internal/service/requests/create_bonus_code.go new file mode 100644 index 0000000..bc2cbf2 --- /dev/null +++ b/internal/service/requests/create_bonus_code.go @@ -0,0 +1,28 @@ +package requests + +import ( + "encoding/json" + "net/http" + "strings" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/rarimo/geo-points-svc/resources" +) + +func NewCreateBonusCode(r *http.Request) (req resources.BonusCodeRequest, err error) { + if err = json.NewDecoder(r.Body).Decode(&req); err != nil { + err = newDecodeError("body", err) + return + } + + req.Data.ID = strings.ToLower(req.Data.ID) + + errs := validation.Errors{ + "data/type": validation.Validate(req.Data.Type, validation.Required, validation.In(resources.BONUS_CODE)), + "data/attributes/nullifier": validation.Validate(req.Data.Attributes.Nullifier, validation.Match(nullifierRegexp)), + "data/attributes/usage_count": validation.Validate(req.Data.Attributes.UsageCount, validation.Min(int(0))), + "data/attributes/reward": validation.Validate(req.Data.Attributes.Reward, validation.Min(int(1))), + } + + return req, errs.Filter() +} diff --git a/internal/service/router.go b/internal/service/router.go index 527bc08..3684b8d 100644 --- a/internal/service/router.go +++ b/internal/service/router.go @@ -31,68 +31,79 @@ func Run(ctx context.Context, cfg config.Config) { ) authMW := handlers.AuthMiddleware(cfg.Auth(), cfg.Log()) - r.Route("/integrations/geo-points-svc/v1", func(r chi.Router) { - r.Route("/public", func(r chi.Router) { - r.Route("/abstraction", func(r chi.Router) { - r.Route("/accounts", func(r chi.Router) { + r.Route("/integrations/geo-points-svc", func(r chi.Router) { + r.Route("/v2", func(r chi.Router) { + r.Route("/public", func(r chi.Router) { + r.Route("/codes", func(r chi.Router) { r.Use(authMW) - r.Post("/", handlers.CreateAbstractionAccount) - r.Get("/{nullifier}", handlers.GetAbstractionAccount) + r.Post("/", handlers.CreateBonusCode) + r.Post("/{bonus_code}", handlers.SubmitBonusCode) }) }) + }) + r.Route("/v1", func(r chi.Router) { + r.Route("/public", func(r chi.Router) { + r.Route("/abstraction", func(r chi.Router) { + r.Route("/accounts", func(r chi.Router) { + r.Use(authMW) + r.Post("/", handlers.CreateAbstractionAccount) + r.Get("/{nullifier}", handlers.GetAbstractionAccount) + }) + }) - r.Route("/balances", func(r chi.Router) { - r.Use(authMW) - r.Post("/", handlers.CreateBalance) - r.Route("/{nullifier}", func(r chi.Router) { - r.Get("/", handlers.GetBalance) - r.Patch("/", handlers.ActivateBalance) - r.Post("/verifypassport", handlers.VerifyInternalPassport) - r.Post("/join_program", handlers.VerifyInternalPassport) - r.Route("/verify", func(r chi.Router) { - r.Post("/external", handlers.VerifyExternalPassport) + r.Route("/balances", func(r chi.Router) { + r.Use(authMW) + r.Post("/", handlers.CreateBalance) + r.Route("/{nullifier}", func(r chi.Router) { + r.Get("/", handlers.GetBalance) + r.Patch("/", handlers.ActivateBalance) + r.Post("/verifypassport", handlers.VerifyInternalPassport) + r.Post("/join_program", handlers.VerifyInternalPassport) + r.Route("/verify", func(r chi.Router) { + r.Post("/external", handlers.VerifyExternalPassport) + }) + r.Post("/withdrawals", handlers.Withdraw) }) - r.Post("/withdrawals", handlers.Withdraw) }) - }) - r.Route("/daily_questions", func(r chi.Router) { - r.Use(authMW) - r.Route("/admin", func(r chi.Router) { - r.Delete("/{question_id}", handlers.DeleteDailyQuestion) - r.Patch("/{question_id}", handlers.EditDailyQuestion) - r.Post("/", handlers.CreateDailyQuestion) - r.Get("/", handlers.FilterStartAtDailyQuestions) + r.Route("/daily_questions", func(r chi.Router) { + r.Use(authMW) + r.Route("/admin", func(r chi.Router) { + r.Delete("/{question_id}", handlers.DeleteDailyQuestion) + r.Patch("/{question_id}", handlers.EditDailyQuestion) + r.Post("/", handlers.CreateDailyQuestion) + r.Get("/", handlers.FilterStartAtDailyQuestions) + }) + r.Route("/{nullifier}", func(r chi.Router) { + r.Get("/status", handlers.GetDailyQuestionsStatus) + r.Get("/", handlers.GetDailyQuestion) + r.Post("/", handlers.CheckDailyQuestion) + }) }) - r.Route("/{nullifier}", func(r chi.Router) { - r.Get("/status", handlers.GetDailyQuestionsStatus) - r.Get("/", handlers.GetDailyQuestion) - r.Post("/", handlers.CheckDailyQuestion) + r.Route("/events", func(r chi.Router) { + r.Use(authMW) + r.Get("/", handlers.ListEvents) + r.Post("/poll", handlers.FulfillPollEvent) + r.Route("/{id}", func(r chi.Router) { + r.Get("/", handlers.GetEvent) + r.Patch("/", handlers.ClaimEvent) + r.Patch("/qrcode", handlers.FulfillQREvent) + }) }) - }) - r.Route("/events", func(r chi.Router) { - r.Use(authMW) - r.Get("/", handlers.ListEvents) - r.Post("/poll", handlers.FulfillPollEvent) - r.Route("/{id}", func(r chi.Router) { - r.Get("/", handlers.GetEvent) - r.Patch("/", handlers.ClaimEvent) - r.Patch("/qrcode", handlers.FulfillQREvent) + r.Get("/balances", handlers.Leaderboard) + r.Route("/event_types", func(r chi.Router) { + r.Get("/", handlers.ListEventTypes) + r.Get("/{name}", handlers.GetEventType) + r.With(authMW).Get("/qr", handlers.ListQREventTypes) + r.With(authMW).Post("/", handlers.CreateEventType) + r.With(authMW).Put("/{name}", handlers.UpdateEventType) }) }) - r.Get("/balances", handlers.Leaderboard) - r.Route("/event_types", func(r chi.Router) { - r.Get("/", handlers.ListEventTypes) - r.Get("/{name}", handlers.GetEventType) - r.With(authMW).Get("/qr", handlers.ListQREventTypes) - r.With(authMW).Post("/", handlers.CreateEventType) - r.With(authMW).Put("/{name}", handlers.UpdateEventType) + // must be accessible only within the cluster + r.Route("/private", func(r chi.Router) { + r.Post("/referrals", handlers.EditReferrals) }) }) - // must be accessible only within the cluster - r.Route("/private", func(r chi.Router) { - r.Post("/referrals", handlers.EditReferrals) - }) }) cfg.Log().Info("Service started") diff --git a/resources/model_bonus_code.go b/resources/model_bonus_code.go new file mode 100644 index 0000000..65d0cca --- /dev/null +++ b/resources/model_bonus_code.go @@ -0,0 +1,43 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +import "encoding/json" + +type BonusCode struct { + Key + Attributes BonusCodeAttributes `json:"attributes"` +} +type BonusCodeRequest struct { + Data BonusCode `json:"data"` + Included Included `json:"included"` +} + +type BonusCodeListRequest struct { + Data []BonusCode `json:"data"` + Included Included `json:"included"` + Links *Links `json:"links"` + Meta json.RawMessage `json:"meta,omitempty"` +} + +func (r *BonusCodeListRequest) PutMeta(v interface{}) (err error) { + r.Meta, err = json.Marshal(v) + return err +} + +func (r *BonusCodeListRequest) GetMeta(out interface{}) error { + return json.Unmarshal(r.Meta, out) +} + +// MustBonusCode - returns BonusCode 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) MustBonusCode(key Key) *BonusCode { + var bonusCode BonusCode + if c.tryFindEntry(key, &bonusCode) { + return &bonusCode + } + return nil +} diff --git a/resources/model_bonus_code_attributes.go b/resources/model_bonus_code_attributes.go new file mode 100644 index 0000000..39c2a5c --- /dev/null +++ b/resources/model_bonus_code_attributes.go @@ -0,0 +1,14 @@ +/* + * GENERATED. Do not modify. Your changes might be overwritten! + */ + +package resources + +type BonusCodeAttributes struct { + // For creating personal bonus codes + Nullifier *string `json:"nullifier,omitempty"` + // Reward for this bonus code + Reward *int `json:"reward,omitempty"` + // Specify how many times bonus code can be scaned. Omit if bonus code must have infinity usage count + UsageCount *int `json:"usage_count,omitempty"` +} diff --git a/resources/model_event_claiming_state_attributes.go b/resources/model_event_claiming_state_attributes.go index ac59b3a..4bbab84 100644 --- a/resources/model_event_claiming_state_attributes.go +++ b/resources/model_event_claiming_state_attributes.go @@ -7,4 +7,6 @@ package resources type EventClaimingStateAttributes struct { // If passport scan event was automatically claimed Claimed bool `json:"claimed"` + // Reward amount in points + Reward int64 `json:"reward"` } diff --git a/resources/model_resource_type.go b/resources/model_resource_type.go index 83fbfc8..af4b44d 100644 --- a/resources/model_resource_type.go +++ b/resources/model_resource_type.go @@ -11,6 +11,7 @@ const ( ABSTRACTION_ACCOUNT ResourceType = "abstraction_account" ACTIVATE_BALANCE ResourceType = "activate_balance" BALANCE ResourceType = "balance" + BONUS_CODE ResourceType = "bonus_code" CLAIM_EVENT ResourceType = "claim_event" CREATE_BALANCE ResourceType = "create_balance" DAILY_QUESTIONS ResourceType = "daily_questions"