Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: free weekly points and sync on event types addition #3

Merged
merged 9 commits into from
Feb 14, 2024
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
Loading