feat(v2): own dca_lp writes + auto-init on first dashboard access
Pairs with the satmachineadmin Phase 1 refactor that hoisted LP state
into `satoshimachine.dca_lp` (one row per user). This extension is now
the WRITER for that table; satmachineadmin only reads it during
distribution.
API surface:
- `GET /api/v1/dca-client/preferences` — returns the LP's
`dca_lp` row, AUTO-CREATING it with the authenticated wallet
as the default DCA destination on first call. Hitting this
endpoint is the act that marks the LP as onboarded on the
operator side (gating their deposit creation).
- `PUT /api/v1/dca-client/preferences` — LP-side update of
wallet / mode / fixed-mode limit / autoforward fields. Ensures
the row exists before applying. Replaces the old
`PUT /autoforward` endpoint (which is gone).
- `GET /api/v1/dca-client/positions` — same shape as before
but also auto-inits dca_lp on entry (so opening the dashboard
onboards the LP). Now INNER JOINs dca_lp so only onboarded
LPs see positions (matches the operator-side "must onboard
before deposits" gate).
- `GET /api/v1/dca-client/transactions` — unchanged.
Models:
- New `LpPreferences` / `UpdateLpPreferences` exposing the
dca_lp fields.
- `UpdateClientAutoforward` removed (replaced by the broader
`UpdateLpPreferences`).
- `PerMachinePosition.dca_mode` now sourced from `dca_lp` (it's
LP-wide, echoed on each position row for legacy display
compatibility).
CRUD:
- `_fetch_user_clients` rewritten: INNER JOIN dca_lp, drop
references to removed `dca_clients.wallet_id` / `.dca_mode`
columns (they don't exist anymore post-Phase-1).
- New: `get_lp_preferences`, `ensure_lp_preferences`,
`update_lp_preferences`. The first writes nothing; the second
is the get-or-create that defends the auto-onboard invariant.
- `update_lp_autoforward` removed — write path is now
`update_lp_preferences` against `dca_lp`, not the multi-row
UPDATE on `dca_clients` that used to be needed because the
state was denormalised across enrolments.
Note: the legacy static/js/index.js in this extension references
endpoints that no longer exist (`/registration-status`, `/register`,
`/dashboard/summary`, ...) — that's pre-existing tech debt from when
the LP UX was moved to ~/dev/webapp. Not regressed by this commit;
the deprecated frontend is out of scope. For now LP onboarding works
via direct API call (curl `GET /preferences` once with the LP's wallet
admin key); the webapp will own the proper UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ade4e67541
commit
7dac898a10
3 changed files with 166 additions and 66 deletions
97
crud.py
97
crud.py
|
|
@ -1,8 +1,9 @@
|
|||
# Satoshi Machine Client v2 — CRUD over admin schema.
|
||||
#
|
||||
# Cross-extension reads of satoshimachine.dca_* tables, filtered by the
|
||||
# LP's user_id. The admin extension owns writes; this client surface is
|
||||
# strictly read + LP-self autoforward toggle.
|
||||
# LP's user_id. The admin extension owns writes to dca_clients/deposits/
|
||||
# settlements; this extension owns writes to satoshimachine.dca_lp
|
||||
# (the LP's per-user preferences row — wallet, mode, autoforward).
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
|
@ -14,8 +15,9 @@ from loguru import logger
|
|||
from .models import (
|
||||
ClientDashboardSummary,
|
||||
ClientTransaction,
|
||||
LpPreferences,
|
||||
PerMachinePosition,
|
||||
UpdateClientAutoforward,
|
||||
UpdateLpPreferences,
|
||||
)
|
||||
|
||||
# Same DB schema as the admin extension — we share satoshimachine.* tables.
|
||||
|
|
@ -24,20 +26,26 @@ db = Database("ext_satoshimachine")
|
|||
|
||||
async def _fetch_user_clients(user_id: str) -> List[dict]:
|
||||
"""All dca_clients rows for this LP, joined with their machines for
|
||||
per-machine display metadata."""
|
||||
per-machine display metadata.
|
||||
|
||||
`dca_mode` lives on the LP's per-user `dca_lp` row now, not per
|
||||
enrolment — INNER JOIN dca_lp so positions only return when the LP
|
||||
has actually onboarded (otherwise distribution can't pay them either,
|
||||
so listing them in the dashboard would be misleading).
|
||||
"""
|
||||
return await db.fetchall(
|
||||
"""
|
||||
SELECT c.id AS client_id,
|
||||
c.machine_id,
|
||||
c.wallet_id,
|
||||
c.dca_mode,
|
||||
c.status,
|
||||
m.machine_npub,
|
||||
m.name AS machine_name,
|
||||
m.location AS machine_location,
|
||||
m.fiat_code AS machine_fiat_code
|
||||
m.fiat_code AS machine_fiat_code,
|
||||
lp.default_dca_mode AS dca_mode
|
||||
FROM satoshimachine.dca_clients c
|
||||
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
|
||||
JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id
|
||||
WHERE c.user_id = :user_id
|
||||
ORDER BY c.created_at DESC
|
||||
""",
|
||||
|
|
@ -207,30 +215,63 @@ async def get_client_transactions(
|
|||
]
|
||||
|
||||
|
||||
async def update_lp_autoforward(
|
||||
user_id: str, data: UpdateClientAutoforward
|
||||
) -> int:
|
||||
"""LPs control their own auto-forward. Update applies to ALL of this
|
||||
LP's dca_clients rows (every machine they're on) — operator can't
|
||||
override LP-controlled settings."""
|
||||
async def get_lp_preferences(user_id: str) -> Optional[LpPreferences]:
|
||||
"""Read this LP's preferences row from `dca_lp`. Returns None if the
|
||||
LP hasn't onboarded yet (no row). Callers in this extension generally
|
||||
use `ensure_lp_preferences` instead, which auto-creates on first
|
||||
access."""
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM satoshimachine.dca_lp WHERE user_id = :uid",
|
||||
{"uid": user_id},
|
||||
LpPreferences,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_lp_preferences(user_id: str, default_wallet_id: str) -> LpPreferences:
|
||||
"""Get-or-create the LP's preferences row.
|
||||
|
||||
First call (no row): seed with `default_wallet_id` (passed by the
|
||||
caller — typically the wallet the LP authenticated through). LP can
|
||||
change it later via `update_lp_preferences`.
|
||||
|
||||
This is the structural enforcement of the "LP must onboard before
|
||||
deposits work" gate: the act of opening satmachineclient and hitting
|
||||
any endpoint creates the dca_lp row, which unlocks deposit creation
|
||||
on the operator side.
|
||||
"""
|
||||
existing = await get_lp_preferences(user_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satoshimachine.dca_lp
|
||||
(user_id, dca_wallet_id, default_dca_mode,
|
||||
autoforward_enabled, created_at, updated_at)
|
||||
VALUES (:uid, :wallet, 'flow', false, :now, :now)
|
||||
""",
|
||||
{"uid": user_id, "wallet": default_wallet_id, "now": now},
|
||||
)
|
||||
created = await get_lp_preferences(user_id)
|
||||
assert created is not None
|
||||
return created
|
||||
|
||||
|
||||
async def update_lp_preferences(
|
||||
user_id: str, data: UpdateLpPreferences
|
||||
) -> Optional[LpPreferences]:
|
||||
"""LP-side update of their `dca_lp` row. Caller must ensure the row
|
||||
exists first (typically via `ensure_lp_preferences` on dashboard
|
||||
load). Operator cannot reach this path — it requires the LP's wallet
|
||||
admin key per the API auth dependency."""
|
||||
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
||||
if not update_data:
|
||||
return 0
|
||||
return await get_lp_preferences(user_id)
|
||||
update_data["updated_at"] = datetime.now()
|
||||
update_data["uid"] = user_id
|
||||
set_clause = ", ".join(
|
||||
f"{k} = :{k}" for k in update_data if k not in ("uid",)
|
||||
)
|
||||
result = await db.execute(
|
||||
f"UPDATE satoshimachine.dca_clients SET {set_clause} WHERE user_id = :uid",
|
||||
set_clause = ", ".join(f"{k} = :{k}" for k in update_data if k not in ("uid",))
|
||||
await db.execute(
|
||||
f"UPDATE satoshimachine.dca_lp SET {set_clause} WHERE user_id = :uid",
|
||||
update_data,
|
||||
)
|
||||
# db.execute return varies by backend; just return count of rows that
|
||||
# exist (best-effort indicator of rows touched).
|
||||
rows = await db.fetchone(
|
||||
"SELECT COUNT(*) AS n FROM satoshimachine.dca_clients WHERE user_id = :uid",
|
||||
{"uid": user_id},
|
||||
)
|
||||
_ = result
|
||||
return int(rows["n"]) if rows else 0
|
||||
|
||||
return await get_lp_preferences(user_id)
|
||||
|
|
|
|||
47
models.py
47
models.py
|
|
@ -15,7 +15,12 @@ from pydantic import BaseModel
|
|||
|
||||
|
||||
class PerMachinePosition(BaseModel):
|
||||
"""LP's position at a single machine."""
|
||||
"""LP's position at a single machine.
|
||||
|
||||
`dca_mode` was previously per-(machine, LP) and is now LP-wide (lives
|
||||
on `dca_lp.default_dca_mode`). Echoed here for legacy UI display only
|
||||
— every position for a given LP shares the same value.
|
||||
"""
|
||||
|
||||
machine_id: str
|
||||
machine_npub: str
|
||||
|
|
@ -48,6 +53,36 @@ class ClientDashboardSummary(BaseModel):
|
|||
positions: List[PerMachinePosition] = []
|
||||
|
||||
|
||||
class LpPreferences(BaseModel):
|
||||
"""LP-controlled DCA preferences (one row per user in `dca_lp`).
|
||||
|
||||
Auto-created on first satmachineclient dashboard access with the LP's
|
||||
authenticated wallet as the default `dca_wallet_id`; they can change
|
||||
any field via `PUT /api/v1/dca-client/preferences`. Distribution
|
||||
reads from here at payout time — operator cannot override.
|
||||
"""
|
||||
|
||||
user_id: str
|
||||
dca_wallet_id: str
|
||||
default_dca_mode: str # 'flow' | 'fixed'
|
||||
fixed_mode_daily_limit: Optional[float]
|
||||
autoforward_ln_address: Optional[str]
|
||||
autoforward_enabled: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UpdateLpPreferences(BaseModel):
|
||||
"""LP-side preference updates. All fields optional; only ones provided
|
||||
are touched. Use to switch DCA wallet, change mode, toggle autoforward."""
|
||||
|
||||
dca_wallet_id: Optional[str] = None
|
||||
default_dca_mode: Optional[str] = None
|
||||
fixed_mode_daily_limit: Optional[float] = None
|
||||
autoforward_ln_address: Optional[str] = None
|
||||
autoforward_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ClientTransaction(BaseModel):
|
||||
"""A single distribution leg landing in the LP's wallet."""
|
||||
|
||||
|
|
@ -64,10 +99,6 @@ class ClientTransaction(BaseModel):
|
|||
transaction_time: datetime
|
||||
|
||||
|
||||
class UpdateClientAutoforward(BaseModel):
|
||||
"""LPs can manage their own auto-forward setting per #8. Applies to all
|
||||
of the LP's dca_clients rows (across every machine they're on)."""
|
||||
|
||||
autoforward_enabled: Optional[bool] = None
|
||||
autoforward_ln_address: Optional[str] = None
|
||||
|
||||
# `UpdateClientAutoforward` removed in the dca_lp refactor; preferences
|
||||
# (autoforward, wallet, mode) now flow through `UpdateLpPreferences`
|
||||
# against `PUT /api/v1/dca-client/preferences`.
|
||||
|
|
|
|||
72
views_api.py
72
views_api.py
|
|
@ -4,8 +4,14 @@
|
|||
# wallet admin key (LP must be an LNbits user; the admin key identifies
|
||||
# them as the wallet's owner).
|
||||
#
|
||||
# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep this
|
||||
# surface stable + minimal.
|
||||
# This extension owns writes to `satoshimachine.dca_lp` — the LP's
|
||||
# per-user preferences row (DCA wallet, mode, autoforward). Reads on
|
||||
# any endpoint auto-init the row using the authenticated wallet as the
|
||||
# default DCA destination, which is the act that satisfies the
|
||||
# operator-side "must onboard before deposits accepted" gate.
|
||||
#
|
||||
# Maintenance-mode note: richer LP UI is moving to ~/dev/webapp. Keep
|
||||
# this surface stable + minimal.
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import List, Optional
|
||||
|
|
@ -15,19 +21,54 @@ from lnbits.core.models import WalletTypeInfo
|
|||
from lnbits.decorators import require_admin_key
|
||||
|
||||
from .crud import (
|
||||
ensure_lp_preferences,
|
||||
get_client_dashboard_summary,
|
||||
get_client_transactions,
|
||||
update_lp_autoforward,
|
||||
update_lp_preferences,
|
||||
)
|
||||
from .models import (
|
||||
ClientDashboardSummary,
|
||||
ClientTransaction,
|
||||
UpdateClientAutoforward,
|
||||
LpPreferences,
|
||||
UpdateLpPreferences,
|
||||
)
|
||||
|
||||
satmachineclient_api_router = APIRouter()
|
||||
|
||||
|
||||
@satmachineclient_api_router.get(
|
||||
"/api/v1/dca-client/preferences", response_model=LpPreferences
|
||||
)
|
||||
async def api_get_preferences(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> LpPreferences:
|
||||
"""Return the LP's DCA preferences. Auto-creates the `dca_lp` row on
|
||||
first call, seeded with the authenticated wallet as the default DCA
|
||||
destination. The act of hitting this endpoint is what marks the LP
|
||||
as "onboarded" on the operator side."""
|
||||
return await ensure_lp_preferences(
|
||||
wallet.wallet.user, default_wallet_id=wallet.wallet.id
|
||||
)
|
||||
|
||||
|
||||
@satmachineclient_api_router.put(
|
||||
"/api/v1/dca-client/preferences", response_model=LpPreferences
|
||||
)
|
||||
async def api_update_preferences(
|
||||
data: UpdateLpPreferences,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> LpPreferences:
|
||||
"""LP-side update of DCA wallet / mode / autoforward. Operator can't
|
||||
reach this path — it requires the LP's wallet admin key."""
|
||||
# Ensure the row exists before update so this endpoint is safe to
|
||||
# call even if the LP somehow hits PUT before GET (they shouldn't,
|
||||
# but the dashboard and any caller order shouldn't matter).
|
||||
await ensure_lp_preferences(wallet.wallet.user, default_wallet_id=wallet.wallet.id)
|
||||
updated = await update_lp_preferences(wallet.wallet.user, data)
|
||||
assert updated is not None
|
||||
return updated
|
||||
|
||||
|
||||
@satmachineclient_api_router.get(
|
||||
"/api/v1/dca-client/positions", response_model=ClientDashboardSummary
|
||||
)
|
||||
|
|
@ -35,8 +76,12 @@ async def api_get_positions(
|
|||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> ClientDashboardSummary:
|
||||
"""LP's aggregated dashboard across every machine they're registered at,
|
||||
plus a per-machine breakdown in the `positions` field."""
|
||||
plus a per-machine breakdown in the `positions` field.
|
||||
|
||||
Also auto-creates the LP's `dca_lp` row on first access (so opening
|
||||
the dashboard is itself the onboarding gesture)."""
|
||||
user_id = wallet.wallet.user
|
||||
await ensure_lp_preferences(user_id, default_wallet_id=wallet.wallet.id)
|
||||
summary = await get_client_dashboard_summary(user_id)
|
||||
if summary is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -62,20 +107,3 @@ async def api_get_transactions(
|
|||
return await get_client_transactions(
|
||||
user_id, limit=limit, offset=offset, machine_id=machine_id
|
||||
)
|
||||
|
||||
|
||||
@satmachineclient_api_router.put(
|
||||
"/api/v1/dca-client/autoforward", response_model=dict
|
||||
)
|
||||
async def api_update_autoforward(
|
||||
data: UpdateClientAutoforward,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> dict:
|
||||
"""LP controls their own auto-forward setting (where DCA distributions
|
||||
get forwarded to externally, per satmachineadmin#8). Applies to all of
|
||||
the LP's dca_clients rows; operator can't override LP-controlled
|
||||
settings."""
|
||||
user_id = wallet.wallet.user
|
||||
n = await update_lp_autoforward(user_id, data)
|
||||
return {"updated_clients": n}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue