diff --git a/db/init.sql b/db/init.sql index 4a9ef91..1dcdd4c 100644 --- a/db/init.sql +++ b/db/init.sql @@ -6,7 +6,8 @@ CREATE TABLE lnauth( ); CREATE TABLE users( pubkey TEXT PRIMARY KEY, - last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + last_seen TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + msats BIGINT NOT NULL DEFAULT 0 ); CREATE TABLE sessions( pubkey TEXT NOT NULL REFERENCES users(pubkey), @@ -42,6 +43,7 @@ CREATE TYPE order_side AS ENUM ('BUY', 'SELL'); CREATE TABLE orders( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE, share_id UUID NOT NULL REFERENCES shares(id), pubkey TEXT NOT NULL REFERENCES users(pubkey), side ORDER_SIDE NOT NULL, diff --git a/db/market.go b/db/market.go index 0aa87e0..402ecd7 100644 --- a/db/market.go +++ b/db/market.go @@ -73,17 +73,17 @@ func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error { query := "" + - "SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at " + + "SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, o.deleted_at, s.description, s.market_id, i.confirmed_at " + "FROM orders o " + "JOIN invoices i ON o.invoice_id = i.id " + "JOIN shares s ON o.share_id = s.id " + - "WHERE " + "WHERE o.deleted_at IS NULL " var args []any if where.MarketId > 0 { - query += "share_id = ANY(SELECT id FROM shares WHERE market_id = $1) " + query += "AND share_id = ANY(SELECT id FROM shares WHERE market_id = $1) " args = append(args, where.MarketId) } else if where.Pubkey != "" { - query += "o.pubkey = $1 " + query += "AND o.pubkey = $1 " args = append(args, where.Pubkey) } if where.Confirmed { @@ -98,7 +98,7 @@ func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error { defer rows.Close() for rows.Next() { var order Order - rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.Share.Description, &order.Share.MarketId, &order.Invoice.ConfirmedAt) + rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.DeletedAt, &order.Share.Description, &order.Share.MarketId, &order.Invoice.ConfirmedAt) *orders = append(*orders, order) } return nil @@ -116,12 +116,12 @@ func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error { func (db *DB) FetchOrder(tx *sql.Tx, ctx context.Context, orderId string, order *Order) error { query := "" + - "SELECT o.id, o.share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at " + + "SELECT o.id, o.share_id, o.pubkey, o.side, o.quantity, o.price, o.created_at, o.deleted_at, s.description, s.market_id, i.confirmed_at, o.invoice_id, COALESCE(i.msats_received, 0) " + "FROM orders o " + - "JOIN invoices i ON o.invoice_id = i.id " + + "LEFT JOIN invoices i ON o.invoice_id = i.id " + "JOIN shares s ON o.share_id = s.id " + "WHERE o.id = $1" - return tx.QueryRowContext(ctx, query, orderId).Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.Share.Description, &order.MarketId, &order.Invoice.ConfirmedAt) + return tx.QueryRowContext(ctx, query, orderId).Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.CreatedAt, &order.DeletedAt, &order.Share.Description, &order.MarketId, &order.Invoice.ConfirmedAt, &order.InvoiceId, &order.Invoice.MsatsReceived) } func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error { @@ -131,7 +131,7 @@ func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error { "FROM orders o " + "LEFT JOIN invoices i ON o.invoice_id = i.id " + "JOIN shares s ON o.share_id = s.id " + - "WHERE o.pubkey = $1 AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) " + + "WHERE o.pubkey = $1 AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) AND o.deleted_at IS NULL " + "ORDER BY o.created_at DESC" rows, err := db.Query(query, pubkey) if err != nil { @@ -153,7 +153,7 @@ func (db *DB) FetchMarketOrders(marketId int64, orders *[]Order) error { "FROM orders o " + "JOIN shares s ON o.share_id = s.id " + "LEFT JOIN invoices i ON o.invoice_id = i.id " + - "WHERE s.market_id = $1 AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) " + + "WHERE s.market_id = $1 AND ( (o.side = 'BUY' AND i.confirmed_at IS NOT NULL) OR o.side = 'SELL' ) AND o.deleted_at IS NULL " + "ORDER BY o.created_at DESC" rows, err := db.Query(query, marketId) if err != nil { diff --git a/db/types.go b/db/types.go index 80ae160..3abdc50 100644 --- a/db/types.go +++ b/db/types.go @@ -52,6 +52,7 @@ type ( Order struct { Id UUID CreatedAt time.Time + DeletedAt null.Time ShareId string `json:"sid"` ShareDescription string Share @@ -59,7 +60,7 @@ type ( Side string `json:"side"` Quantity int64 `json:"quantity"` Price int64 `json:"price"` - InvoiceId UUID + InvoiceId null.String Invoice OrderId UUID } diff --git a/server/router/handler/market.go b/server/router/handler/market.go index 7e28f51..b4fd7dc 100644 --- a/server/router/handler/market.go +++ b/server/router/handler/market.go @@ -208,7 +208,7 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { } // Create (unconfirmed) order - o.InvoiceId = invoice.Id + o.InvoiceId.String = invoice.Id if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil { tx.Rollback() return err @@ -217,8 +217,6 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { tx.Commit() go sc.Lnd.CheckInvoice(sc.Db, hash) - // TODO: find matching orders - data = map[string]any{ "id": invoice.Id, "bolt11": invoice.PaymentRequest, @@ -247,6 +245,82 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc { } } +func HandleDeleteOrder(sc context.ServerContext) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + orderId string + tx *sql.Tx + u db.User + o db.Order + msats int64 + err error + ) + + if orderId = c.Param("id"); orderId == "" { + return echo.NewHTTPError(http.StatusBadRequest) + } + u = c.Get("session").(db.User) + + // transaction start + ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + if tx, err = sc.Db.BeginTx(ctx, nil); err != nil { + return err + } + defer tx.Commit() + + if err = sc.Db.FetchOrder(tx, ctx, orderId, &o); err != nil { + tx.Rollback() + return err + } + + if u.Pubkey != o.Pubkey { + // order does not belong to user + tx.Rollback() + return echo.NewHTTPError(http.StatusForbidden) + } + + if o.OrderId != "" { + // order already settled + tx.Rollback() + return echo.NewHTTPError(http.StatusBadRequest) + } + + if o.DeletedAt.Valid { + // order already deleted + tx.Rollback() + return echo.NewHTTPError(http.StatusBadRequest) + } + + if o.Invoice.ConfirmedAt.Valid { + // order already paid: we need to move paid sats to user balance before deleting the order + msats = o.Invoice.MsatsReceived + if res, err := tx.ExecContext(ctx, "UPDATE users SET msats = msats + $1 WHERE pubkey = $2", msats, u.Pubkey); err != nil { + tx.Rollback() + return err + } else { + // make sure exactly one row was affected + if rowsAffected, err := res.RowsAffected(); err != nil { + tx.Rollback() + return err + } else if rowsAffected != 1 { + tx.Rollback() + return echo.NewHTTPError(http.StatusInternalServerError) + } + } + } + + if _, err = tx.ExecContext(ctx, "UPDATE orders SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1", o.Id); err != nil { + tx.Rollback() + return err + } + + tx.Commit() + + return c.JSON(http.StatusOK, nil) + } +} + func HandleOrders(sc context.ServerContext) echo.HandlerFunc { return func(c echo.Context) error { var ( diff --git a/server/router/router.go b/server/router/router.go index e6cfb46..7d3fa2a 100644 --- a/server/router/router.go +++ b/server/router/router.go @@ -52,6 +52,9 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) { handler.HandleOrder, middleware.SessionGuard, middleware.LNDGuard) + DELETE(e, sc, "/api/order/:id", + handler.HandleDeleteOrder, + middleware.SessionGuard) GET(e, sc, "/api/orders", handler.HandleOrders, middleware.SessionGuard) @@ -75,6 +78,10 @@ func POST(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...M return e.POST(path, scF(sc), toMiddlewareFunc(sc, scM...)...) } +func DELETE(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route { + return e.DELETE(path, scF(sc), toMiddlewareFunc(sc, scM...)...) +} + func Use(e *echo.Echo, sc ServerContext, scM ...MiddlewareFunc) { e.Use(toMiddlewareFunc(sc, scM...)...) } diff --git a/vue/src/components/MarketOrders.vue b/vue/src/components/MarketOrders.vue index fdeae6e..f246218 100644 --- a/vue/src/components/MarketOrders.vue +++ b/vue/src/components/MarketOrders.vue @@ -8,8 +8,8 @@ - + @@ -36,7 +36,7 @@ const mouseover = (oid) => { } } -const click = (order) => { +const onMatchClick = (order) => { // redirect to form with prefilled inputs to match order if (order.side === 'BUY') { // match BUY YES with BUY NO and vice versa diff --git a/vue/src/components/OrderRow.vue b/vue/src/components/OrderRow.vue index d3edce7..5ac1c22 100644 --- a/vue/src/components/OrderRow.vue +++ b/vue/src/components/OrderRow.vue @@ -5,8 +5,9 @@ {{ ago(new Date(order.CreatedAt)) }} {{ order.Status }} - - + + + @@ -17,13 +18,14 @@ import ago from 's-ago' import { useSession } from '@/stores/session' const session = useSession() -const props = defineProps(['order', 'selected', 'click']) +const props = defineProps(['order', 'selected', 'onMatchClick']) const order = ref(props.order) const showContextMenu = ref(false) -const click = ref(props.click) - -const mine = order.value.Pubkey === session?.pubkey +const onMatchClick = ref(props.onMatchClick) +const mine = computed(() => order.value.Pubkey === session?.pubkey) +const showMatch = computed(() => !mine.value && order.value.Status !== 'EXECUTED') +const showCancel = computed(() => mine.value && order.value.Status !== 'EXECUTED') const statusClassName = computed(() => { const status = order.value.Status @@ -43,15 +45,18 @@ const selectedClassName = computed(() => { }) const mouseover = () => { - if (!!props.click && order.value.Status === 'PENDING') { - showContextMenu.value = true - } + showContextMenu.value = true && !!session.pubkey } const mouseleave = () => { showContextMenu.value = false } +const cancelOrder = async () => { + const url = '/api/order/' + order.value.Id + await fetch(url, { method: 'DELETE' }).catch(console.error) +} +