Skip to content

Commit

Permalink
Merge pull request #3 from rarimo/feature/weekly-reserve
Browse files Browse the repository at this point in the history
Feature: free weekly points and sync on event types addition
  • Loading branch information
olegfomenko authored Feb 14, 2024
2 parents ba95827 + bd4778f commit d172741
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 109 deletions.
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ event_types:
description: Lorem ipsum dolor sit amet
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
- name: daily_login
title: Daily login
reward: 5
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/components/schemas/Balance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ allOf:
type: object
required:
- amount
- is_verified
- created_at
- updated_at
properties:
amount:
type: integer
description: Amount of points
example: 580
is_verified:
type: boolean
description: Whether the user has scanned passport
example: true
created_at:
type: integer
description: Unix timestamp of balance creation
Expand Down
10 changes: 6 additions & 4 deletions internal/assets/migrations/001_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ AS $$ BEGIN NEW.updated_at = EXTRACT('EPOCH' FROM NOW()); RETURN NEW; END; $$;

CREATE TABLE IF NOT EXISTS balances
(
did text PRIMARY KEY,
amount integer not null default 0,
created_at integer not null default EXTRACT('EPOCH' FROM NOW()),
updated_at integer not null default EXTRACT('EPOCH' FROM NOW())
did text PRIMARY KEY,
amount integer not null default 0,
created_at integer not null default EXTRACT('EPOCH' FROM NOW()),
updated_at integer not null default EXTRACT('EPOCH' FROM NOW()),
passport_hash text UNIQUE,
passport_expires timestamp without time zone
);

CREATE INDEX IF NOT EXISTS balances_amount_index ON balances using btree (amount);
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ func Run(args []string) bool {
switch cmd {
case serviceCmd.FullCommand():
run(func(context.Context, config.Config) { sbtcheck.Run(ctx, cfg) })
run(reopener.Run)
run(service.Run)
run(reopener.Run)
case migrateUpCmd.FullCommand():
err = MigrateUp(cfg)
case migrateDownCmd.FullCommand():
Expand Down
16 changes: 11 additions & 5 deletions internal/data/balances.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
package data

import (
"database/sql"
"time"

"gitlab.com/distributed_lab/kit/pgdb"
)

type Balance struct {
DID string `db:"did"`
Amount int32 `db:"amount"`
CreatedAt int32 `db:"created_at"`
UpdatedAt int32 `db:"updated_at"`
Rank *int `db:"rank"`
DID string `db:"did"`
Amount int32 `db:"amount"`
CreatedAt int32 `db:"created_at"`
UpdatedAt int32 `db:"updated_at"`
PassportHash sql.NullString `db:"passport_hash"`
PassportExpires sql.NullTime `db:"passport_expires"`
Rank *int `db:"rank"`
}

type BalancesQ interface {
New() BalancesQ
Insert(did string) error
UpdateAmountBy(points int32) error
SetPassport(hash string, exp time.Time) error

Page(*pgdb.OffsetPageParams) BalancesQ
Select() ([]Balance, error)
Expand Down
15 changes: 14 additions & 1 deletion internal/data/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,30 @@ type Event struct {
PointsAmount sql.NullInt32 `db:"points_amount"`
}

// ReopenableEvent is a pair that is sufficient to build a new open event with a specific type for a user
type ReopenableEvent struct {
UserDID string `db:"user_did"`
Type string `db:"type"`
}

type EventsQ interface {
New() EventsQ
Insert(...Event) error
Update(status EventStatus, meta json.RawMessage, points *int32) (*Event, error)
Reopen() (count uint, err error)
Transaction(func() error) error

Page(*pgdb.CursorPageParams) EventsQ
Select() ([]Event, error)
Get() (*Event, error)
// Count returns the total number of events that match filters. Page is not
// applied to this method.
Count() (int, error)
// SelectReopenable returns events matching criteria: there are no open or
// fulfilled events of this type for a specific user.
SelectReopenable() ([]ReopenableEvent, error)
// SelectAbsentTypes returns events matching criteria: there are no events of
// this type for a specific user. Filters are not applied to this selection.
SelectAbsentTypes(allTypes ...string) ([]ReopenableEvent, error)

FilterByID(string) EventsQ
FilterByUserDID(string) EventsQ
Expand Down
18 changes: 10 additions & 8 deletions internal/data/evtypes/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package evtypes

import (
"database/sql"
"time"

"github.com/rarimo/rarime-points-svc/internal/data"
Expand All @@ -22,7 +21,10 @@ const (
Custom Frequency = "custom"
)

const TypeGetPoH = "get_poh"
const (
TypeGetPoH = "get_poh"
TypeFreeWeekly = "free_weekly"
)

type Types struct {
inner map[string]resources.EventStaticMeta
Expand All @@ -45,15 +47,15 @@ func (t Types) PrepareOpenEvents(userDID string) []data.Event {
evTypes := t.List()
events := make([]data.Event, len(evTypes))

for i, evType := range evTypes {
for i, et := range evTypes {
events[i] = data.Event{
UserDID: userDID,
Type: evType.Name,
Type: et.Name,
Status: data.EventOpen,
PointsAmount: sql.NullInt32{
Int32: evType.Reward,
Valid: true,
},
}

if et.Name == TypeFreeWeekly {
events[i].Status = data.EventFulfilled
}
}

Expand Down
13 changes: 13 additions & 0 deletions internal/data/pg/balances.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"time"

"github.com/Masterminds/squirrel"
"github.com/rarimo/rarime-points-svc/internal/data"
Expand Down Expand Up @@ -50,6 +51,18 @@ func (q *balances) UpdateAmountBy(points int32) error {
return nil
}

func (q *balances) SetPassport(hash string, exp time.Time) error {
stmt := q.updater.
Set("passport_hash", hash).
Set("passport_expires", exp)

if err := q.db.Exec(stmt); err != nil {
return fmt.Errorf("set passport hash and expires: %w", err)
}

return nil
}

func (q *balances) Page(page *pgdb.OffsetPageParams) data.BalancesQ {
q.selector = page.ApplyTo(q.selector, "amount")
return q
Expand Down
88 changes: 57 additions & 31 deletions internal/data/pg/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/Masterminds/squirrel"
"github.com/rarimo/rarime-points-svc/internal/data"
Expand All @@ -14,18 +15,20 @@ import (
const eventsTable = "events"

type events struct {
db *pgdb.DB
selector squirrel.SelectBuilder
updater squirrel.UpdateBuilder
counter squirrel.SelectBuilder
db *pgdb.DB
selector squirrel.SelectBuilder
updater squirrel.UpdateBuilder
counter squirrel.SelectBuilder
reopenable squirrel.SelectBuilder
}

func NewEvents(db *pgdb.DB) data.EventsQ {
return &events{
db: db,
selector: squirrel.Select("*").From(eventsTable),
updater: squirrel.Update(eventsTable),
counter: squirrel.Select("count(id) AS count").From(eventsTable),
db: db,
selector: squirrel.Select("*").From(eventsTable),
updater: squirrel.Update(eventsTable),
counter: squirrel.Select("count(id) AS count").From(eventsTable),
reopenable: squirrel.Select("user_did", "type").Distinct().From(eventsTable + " e1"),
}
}

Expand Down Expand Up @@ -76,36 +79,15 @@ func (q *events) Update(status data.EventStatus, meta json.RawMessage, points *i
return &res, nil
}

func (q *events) Reopen() (count uint, err error) {
stmt := q.updater.SetMap(map[string]any{"status": data.EventOpen})
defer func() {
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
}()

res, err := q.db.ExecWithResult(stmt)
if err != nil {
return 0, fmt.Errorf("update status to open with result: %w", err)
}

rows, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("get rows affected: %w", err)
}

return uint(rows), nil
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")
return q
}

func (q *events) Transaction(f func() error) error {
return q.db.Transaction(f)
}

func (q *events) Select() ([]data.Event, error) {
var res []data.Event

Expand Down Expand Up @@ -141,6 +123,49 @@ func (q *events) Count() (int, error) {
return res.Count, nil
}

func (q *events) SelectReopenable() ([]data.ReopenableEvent, error) {
subq := fmt.Sprintf(`NOT EXISTS (
SELECT 1 FROM %s e2
WHERE e2.user_did = e1.user_did
AND e2.type = e1.type
AND e2.status IN (?, ?))`, eventsTable)
stmt := q.reopenable.Where(subq, data.EventOpen, data.EventFulfilled)

var res []data.ReopenableEvent
if err := q.db.Select(&res, stmt); err != nil {
return nil, fmt.Errorf("select reopenable events: %w", err)
}

return res, nil
}

func (q *events) SelectAbsentTypes(allTypes ...string) ([]data.ReopenableEvent, error) {
values := make([]string, len(allTypes))
for i, t := range allTypes {
values[i] = fmt.Sprintf("('%s')", t)
}

query := fmt.Sprintf(`
WITH types(type) AS (
VALUES %s
)
SELECT u.user_did, t.type
FROM (
SELECT DISTINCT user_did FROM %s
) u
CROSS JOIN types t
LEFT JOIN %s e ON e.user_did = u.user_did AND e.type = t.type
WHERE e.type IS NULL;
`, strings.Join(values, ", "), eventsTable, eventsTable)

var res []data.ReopenableEvent
if err := q.db.SelectRaw(&res, query); err != nil {
return nil, fmt.Errorf("select absent types for each did: %w", err)
}

return res, nil
}

func (q *events) FilterByID(id string) data.EventsQ {
return q.applyCondition(squirrel.Eq{"id": id})
}
Expand Down Expand Up @@ -171,5 +196,6 @@ func (q *events) applyCondition(cond squirrel.Sqlizer) data.EventsQ {
q.selector = q.selector.Where(cond)
q.updater = q.updater.Where(cond)
q.counter = q.counter.Where(cond)
q.reopenable = q.reopenable.Where(cond)
return q
}
9 changes: 5 additions & 4 deletions internal/service/handlers/get_balance.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ func newBalanceModel(balance data.Balance) resources.Balance {
Type: resources.BALANCE,
},
Attributes: resources.BalanceAttributes{
Amount: balance.Amount,
CreatedAt: balance.CreatedAt,
UpdatedAt: balance.UpdatedAt,
Rank: balance.Rank,
Amount: balance.Amount,
IsVerified: balance.PassportHash.Valid,
CreatedAt: balance.CreatedAt,
UpdatedAt: balance.UpdatedAt,
Rank: balance.Rank,
},
}
}
Expand Down
19 changes: 15 additions & 4 deletions internal/service/handlers/withdraw.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handlers
import (
"fmt"
"net/http"
"time"

cosmos "github.com/cosmos/cosmos-sdk/types"
bank "github.com/cosmos/cosmos-sdk/x/bank/types"
Expand All @@ -21,7 +22,7 @@ func Withdraw(w http.ResponseWriter, r *http.Request) {
return
}

if !isEnoughPoints(req, w, r) {
if !isEligibleToWithdraw(req, w, r) {
return
}

Expand Down Expand Up @@ -80,19 +81,29 @@ func newWithdrawResponse(w data.Withdrawal, balance data.Balance) *resources.Wit
return &resp
}

func isEnoughPoints(req resources.WithdrawRequest, w http.ResponseWriter, r *http.Request) bool {
func isEligibleToWithdraw(req resources.WithdrawRequest, w http.ResponseWriter, r *http.Request) bool {
balance := getBalanceByDID(req.Data.ID, false, w, r)
if balance == nil {
return false
}

if balance.Amount < req.Data.Attributes.Amount {
render := func(field, format string, a ...any) bool {
ape.RenderErr(w, problems.BadRequest(validation.Errors{
"data/attributes/amount": fmt.Errorf("insufficient balance: %d", balance.Amount),
field: fmt.Errorf(format, a...),
})...)
return false
}

if !balance.PassportHash.Valid {
return render("is_verified", "user must have verified passport for withdrawals")
}
if balance.PassportExpires.Time.Before(time.Now().UTC()) {
return render("is_verified", "user passport is expired")
}
if balance.Amount < req.Data.Attributes.Amount {
return render("data/attributes/amount", "insufficient balance: %d", balance.Amount)
}

return true
}

Expand Down
Loading

0 comments on commit d172741

Please sign in to comment.