feat(v2)(ui): operator-side LP UI matches the new dca_lp authority split
Operator no longer chooses the LP's wallet / DCA mode / autoforward — those belong to the LP, written via satmachineclient. The Add LP / Edit LP dialogs reduce to (machine, user_id, optional username, status). The clients table loses the wallet / mode / autoforward columns and gains an "Onboarded" column showing whether the LP has a `dca_lp` row yet (server-side LEFT JOIN; `DcaClient.lp_onboarded`). Deposit creation gate (the structural enforcement of "must onboard first"): - Picker annotates each LP option with "— pending onboarding" and disables un-onboarded LP rows. - Selecting an un-onboarded LP shows an inline deep-orange banner explaining the LP needs to open satmachineclient once. - The Record button is `:disable`d in that state. The backend refuses with HTTP 422 anyway (see previous commit) — UI is just the first line of feedback. Backend wiring: - `DcaClient` model gains `lp_onboarded: bool = False`, populated at SELECT time via a shared `_CLIENT_SELECT` / `_CLIENT_FROM` fragment that LEFT JOINs `dca_lp` on `user_id`. All four list/ single-row read paths use it: by-id, by-(machine,user), by-machine, by-operator, by-user. No extra round-trip per row. - CSV export drops the removed columns; adds `lp_onboarded`. All 86 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
80b5a6d785
commit
cfad4e341c
4 changed files with 93 additions and 103 deletions
44
crud.py
44
crud.py
|
|
@ -201,9 +201,23 @@ async def create_dca_client(data: CreateDcaClientData) -> DcaClient:
|
|||
return client
|
||||
|
||||
|
||||
# Shared SELECT fragment: client columns plus the LP-onboarded flag
|
||||
# computed via LEFT JOIN on dca_lp. Returned as `lp_onboarded` (boolean
|
||||
# 0/1 in SQLite, which Pydantic coerces to bool on the DcaClient model).
|
||||
_CLIENT_SELECT = """
|
||||
c.id, c.machine_id, c.user_id, c.username, c.status,
|
||||
c.created_at, c.updated_at,
|
||||
(lp.user_id IS NOT NULL) AS lp_onboarded
|
||||
"""
|
||||
_CLIENT_FROM = (
|
||||
"satoshimachine.dca_clients c "
|
||||
"LEFT JOIN satoshimachine.dca_lp lp ON lp.user_id = c.user_id"
|
||||
)
|
||||
|
||||
|
||||
async def get_dca_client(client_id: str) -> Optional[DcaClient]:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM satoshimachine.dca_clients WHERE id = :id",
|
||||
f"SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM} WHERE c.id = :id",
|
||||
{"id": client_id},
|
||||
DcaClient,
|
||||
)
|
||||
|
|
@ -213,9 +227,9 @@ async def get_dca_client_for_machine_user(
|
|||
machine_id: str, user_id: str
|
||||
) -> Optional[DcaClient]:
|
||||
return await db.fetchone(
|
||||
"""
|
||||
SELECT * FROM satoshimachine.dca_clients
|
||||
WHERE machine_id = :machine_id AND user_id = :user_id
|
||||
f"""
|
||||
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||
WHERE c.machine_id = :machine_id AND c.user_id = :user_id
|
||||
""",
|
||||
{"machine_id": machine_id, "user_id": user_id},
|
||||
DcaClient,
|
||||
|
|
@ -224,10 +238,10 @@ async def get_dca_client_for_machine_user(
|
|||
|
||||
async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
|
||||
return await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM satoshimachine.dca_clients
|
||||
WHERE machine_id = :machine_id
|
||||
ORDER BY created_at DESC
|
||||
f"""
|
||||
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||
WHERE c.machine_id = :machine_id
|
||||
ORDER BY c.created_at DESC
|
||||
""",
|
||||
{"machine_id": machine_id},
|
||||
DcaClient,
|
||||
|
|
@ -237,9 +251,9 @@ async def get_dca_clients_for_machine(machine_id: str) -> List[DcaClient]:
|
|||
async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]:
|
||||
"""All clients across every machine this operator owns."""
|
||||
return await db.fetchall(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM satoshimachine.dca_clients c
|
||||
f"""
|
||||
SELECT {_CLIENT_SELECT}
|
||||
FROM {_CLIENT_FROM}
|
||||
JOIN satoshimachine.dca_machines m ON m.id = c.machine_id
|
||||
WHERE m.operator_user_id = :uid
|
||||
ORDER BY c.created_at DESC
|
||||
|
|
@ -252,10 +266,10 @@ async def get_dca_clients_for_operator(operator_user_id: str) -> List[DcaClient]
|
|||
async def get_dca_clients_for_user(user_id: str) -> List[DcaClient]:
|
||||
"""LP cross-operator view — every machine this LP is registered at."""
|
||||
return await db.fetchall(
|
||||
"""
|
||||
SELECT * FROM satoshimachine.dca_clients
|
||||
WHERE user_id = :user_id
|
||||
ORDER BY created_at DESC
|
||||
f"""
|
||||
SELECT {_CLIENT_SELECT} FROM {_CLIENT_FROM}
|
||||
WHERE c.user_id = :user_id
|
||||
ORDER BY c.created_at DESC
|
||||
""",
|
||||
{"user_id": user_id},
|
||||
DcaClient,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,10 @@ class DcaClient(BaseModel):
|
|||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Computed at SELECT time via LEFT JOIN on dca_lp. Lets the operator
|
||||
# UI render "pending onboarding" badges and disable deposit creation
|
||||
# without a second round-trip per row.
|
||||
lp_onboarded: bool = False
|
||||
|
||||
|
||||
class UpdateDcaClientData(BaseModel):
|
||||
|
|
|
|||
|
|
@ -141,18 +141,21 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
clientsTable: {
|
||||
// Wallet / mode / autoforward dropped — those are LP-controlled
|
||||
// via satmachineclient, not the operator's concern. `onboarded`
|
||||
// surfaces the dca_lp existence flag (lp_onboarded) so operators
|
||||
// can see at a glance which LPs still need to register before
|
||||
// deposits can be recorded against them.
|
||||
columns: [
|
||||
{name: 'machine', label: 'Machine', field: 'machine_id', align: 'left'},
|
||||
{name: 'username', label: 'LP', field: 'username', align: 'left'},
|
||||
{name: 'wallet_id', label: 'Wallet', field: 'wallet_id', align: 'left'},
|
||||
{name: 'dca_mode', label: 'Mode', field: 'dca_mode', align: 'left'},
|
||||
{name: 'onboarded', label: 'Onboarded', field: 'lp_onboarded', align: 'center'},
|
||||
{
|
||||
name: 'remaining_balance',
|
||||
label: 'Balance',
|
||||
field: 'remaining_balance',
|
||||
align: 'right'
|
||||
},
|
||||
{name: 'autoforward', label: '→', field: 'autoforward_enabled', align: 'center'},
|
||||
{name: 'status', label: 'Status', field: 'status', align: 'left'},
|
||||
{name: 'actions', label: '', field: 'id', align: 'right'}
|
||||
],
|
||||
|
|
@ -257,11 +260,24 @@ window.app = Vue.createApp({
|
|||
}))
|
||||
},
|
||||
depositClientOptions() {
|
||||
// Annotate each LP option with onboarding state so the operator
|
||||
// sees at-pick time which LPs can accept deposits. We don't hide
|
||||
// un-onboarded LPs — the operator might want to know they exist
|
||||
// and chase them — but submission is gated below by
|
||||
// `selectedDepositClient.lp_onboarded`.
|
||||
return this.clients.map(c => ({
|
||||
label: `${c.username || this.shortId(c.user_id)} @ ${this.machineNameById(c.machine_id)}`,
|
||||
value: c.id
|
||||
label:
|
||||
`${c.username || this.shortId(c.user_id)} @ ` +
|
||||
`${this.machineNameById(c.machine_id)}` +
|
||||
(c.lp_onboarded ? '' : ' — pending onboarding'),
|
||||
value: c.id,
|
||||
disable: !c.lp_onboarded
|
||||
}))
|
||||
},
|
||||
selectedDepositClient() {
|
||||
const id = this.depositDialog.data.client_id
|
||||
return id ? this.clients.find(c => c.id === id) : null
|
||||
},
|
||||
worklistBuckets() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -559,9 +575,9 @@ window.app = Vue.createApp({
|
|||
})
|
||||
this._downloadCsv(
|
||||
'clients.csv',
|
||||
['id', 'machine_id', 'machine_name', 'user_id', 'wallet_id',
|
||||
'username', 'dca_mode', 'status', 'autoforward_enabled',
|
||||
'autoforward_ln_address', 'total_deposits', 'total_payments',
|
||||
['id', 'machine_id', 'machine_name', 'user_id',
|
||||
'username', 'lp_onboarded', 'status',
|
||||
'total_deposits', 'total_payments',
|
||||
'remaining_balance', 'balance_currency', 'created_at'],
|
||||
rows
|
||||
)
|
||||
|
|
@ -881,12 +897,7 @@ window.app = Vue.createApp({
|
|||
id: client.id,
|
||||
machine_id: client.machine_id,
|
||||
user_id: client.user_id,
|
||||
wallet_id: client.wallet_id,
|
||||
username: client.username || '',
|
||||
dca_mode: client.dca_mode,
|
||||
fixed_mode_daily_limit: client.fixed_mode_daily_limit,
|
||||
autoforward_enabled: !!client.autoforward_enabled,
|
||||
autoforward_ln_address: client.autoforward_ln_address || '',
|
||||
status: client.status
|
||||
}
|
||||
this.clientDialog.show = true
|
||||
|
|
@ -1277,15 +1288,13 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
_emptyClientForm() {
|
||||
// Operator-side LP enrolment is just (machine, user, optional
|
||||
// display name). Wallet / mode / autoforward are LP-controlled
|
||||
// via satmachineclient — operator can't pick or change them.
|
||||
return {
|
||||
machine_id: null,
|
||||
user_id: '',
|
||||
wallet_id: '',
|
||||
username: '',
|
||||
dca_mode: 'flow',
|
||||
fixed_mode_daily_limit: null,
|
||||
autoforward_enabled: false,
|
||||
autoforward_ln_address: '',
|
||||
status: 'active'
|
||||
}
|
||||
},
|
||||
|
|
@ -1294,30 +1303,13 @@ window.app = Vue.createApp({
|
|||
return {
|
||||
machine_id: d.machine_id,
|
||||
user_id: (d.user_id || '').trim(),
|
||||
wallet_id: (d.wallet_id || '').trim(),
|
||||
username: (d.username || '').trim() || null,
|
||||
dca_mode: d.dca_mode || 'flow',
|
||||
fixed_mode_daily_limit:
|
||||
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
|
||||
? Number(d.fixed_mode_daily_limit) : null,
|
||||
autoforward_enabled: !!d.autoforward_enabled,
|
||||
autoforward_ln_address:
|
||||
d.autoforward_enabled && d.autoforward_ln_address
|
||||
? d.autoforward_ln_address.trim() : null
|
||||
username: (d.username || '').trim() || null
|
||||
}
|
||||
},
|
||||
|
||||
_cleanClientUpdate(d) {
|
||||
return {
|
||||
username: (d.username || '').trim() || null,
|
||||
dca_mode: d.dca_mode,
|
||||
fixed_mode_daily_limit:
|
||||
d.dca_mode === 'fixed' && d.fixed_mode_daily_limit
|
||||
? Number(d.fixed_mode_daily_limit) : null,
|
||||
autoforward_enabled: !!d.autoforward_enabled,
|
||||
autoforward_ln_address:
|
||||
d.autoforward_enabled && d.autoforward_ln_address
|
||||
? d.autoforward_ln_address.trim() : null,
|
||||
status: d.status
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -222,13 +222,21 @@
|
|||
<q-td key="username">
|
||||
<span v-text="props.row.username || shortId(props.row.user_id)"></span>
|
||||
</q-td>
|
||||
<q-td key="wallet_id">
|
||||
<code :style="{fontSize: '0.8em'}"
|
||||
v-text="shortId(props.row.wallet_id)"></code>
|
||||
</q-td>
|
||||
<q-td key="dca_mode">
|
||||
<q-badge :color="props.row.dca_mode === 'flow' ? 'blue' : 'purple'"
|
||||
:label="props.row.dca_mode"></q-badge>
|
||||
<q-td key="onboarded">
|
||||
<q-icon v-if="props.row.lp_onboarded"
|
||||
name="check_circle" color="green" size="sm">
|
||||
<q-tooltip>
|
||||
LP has onboarded via satmachineclient.
|
||||
Deposits and DCA distributions can proceed.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else name="pending" color="deep-orange" size="sm">
|
||||
<q-tooltip>
|
||||
LP hasn't installed/opened satmachineclient yet.
|
||||
Deposits will be refused until they register and
|
||||
select a DCA wallet.
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td key="remaining_balance" class="text-right">
|
||||
<span v-if="clientBalances[props.row.id]"
|
||||
|
|
@ -236,18 +244,6 @@
|
|||
v-text="formatFiat(clientBalances[props.row.id].remaining_balance, clientBalances[props.row.id].currency)"></span>
|
||||
<span v-else :style="{opacity: 0.5}">…</span>
|
||||
</q-td>
|
||||
<q-td key="autoforward">
|
||||
<q-icon v-if="props.row.autoforward_enabled"
|
||||
name="forward_to_inbox" color="primary" size="sm">
|
||||
<q-tooltip>
|
||||
Auto-forward enabled →
|
||||
<span v-text="props.row.autoforward_ln_address || '(no address)'"></span>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else name="forward" color="grey-5" size="sm">
|
||||
<q-tooltip>Auto-forward disabled</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td key="status">
|
||||
<q-badge
|
||||
:color="props.row.status === 'active' ? 'green' : 'grey'"
|
||||
|
|
@ -1128,6 +1124,17 @@
|
|||
class="q-mb-md" dense outlined
|
||||
:rules="[v => !!v || 'Pick an LP']"></q-select>
|
||||
|
||||
<q-banner v-if="depositDialog.mode === 'add' && selectedDepositClient && !selectedDepositClient.lp_onboarded"
|
||||
class="bg-deep-orange-1 text-grey-9 q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="pending" color="deep-orange"></q-icon>
|
||||
</template>
|
||||
This LP hasn't onboarded via <b>satmachineclient</b> yet, so
|
||||
their DCA wallet isn't configured. Ask them to open the
|
||||
satmachineclient extension once and the deposit will be
|
||||
accepted next time.
|
||||
</q-banner>
|
||||
|
||||
<q-input v-model.number="depositDialog.data.amount"
|
||||
label="Amount (fiat)"
|
||||
type="number" step="0.01" min="0"
|
||||
|
|
@ -1148,6 +1155,7 @@
|
|||
<q-btn color="primary"
|
||||
:label="depositDialog.mode === 'add' ? 'Record' : 'Save'"
|
||||
:loading="depositDialog.saving"
|
||||
:disable="depositDialog.mode === 'add' && (!selectedDepositClient || !selectedDepositClient.lp_onboarded)"
|
||||
@click="submitDeposit"></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
|
|
@ -1195,9 +1203,10 @@
|
|||
<q-card-section>
|
||||
<p class="text-caption q-mb-md" :style="{opacity: 0.7}"
|
||||
v-if="clientDialog.mode === 'add'">
|
||||
LPs receive DCA distributions proportional to their remaining
|
||||
balance. Each LP is scoped to a single machine; the same LP user
|
||||
can register at multiple machines as separate rows.
|
||||
Enrol an LP at one of your machines. Wallet, DCA mode, and
|
||||
autoforward are configured by the LP themselves via the
|
||||
<b>satmachineclient</b> extension — you can't set them here.
|
||||
Deposits are refused until the LP has registered.
|
||||
</p>
|
||||
|
||||
<q-select
|
||||
|
|
@ -1217,38 +1226,9 @@
|
|||
class="q-mb-md" dense outlined
|
||||
:rules="[v => !!v || 'Required']"></q-input>
|
||||
|
||||
<q-input v-if="clientDialog.mode === 'add'"
|
||||
v-model="clientDialog.data.wallet_id"
|
||||
label="LP's wallet_id (receives DCA)"
|
||||
hint="The LP's wallet where their sats land"
|
||||
class="q-mb-md" dense outlined
|
||||
:rules="[v => !!v || 'Required']"></q-input>
|
||||
|
||||
<q-input v-model="clientDialog.data.username"
|
||||
label="Display name (optional)"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
|
||||
<q-select v-model="clientDialog.data.dca_mode"
|
||||
:options="[{label: 'Flow (proportional)', value: 'flow'},
|
||||
{label: 'Fixed (daily limit)', value: 'fixed'}]"
|
||||
label="DCA mode"
|
||||
emit-value map-options
|
||||
class="q-mb-md" dense outlined></q-select>
|
||||
|
||||
<q-input v-if="clientDialog.data.dca_mode === 'fixed'"
|
||||
v-model.number="clientDialog.data.fixed_mode_daily_limit"
|
||||
label="Fixed mode daily limit (fiat)"
|
||||
type="number" step="0.01"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
|
||||
<q-toggle v-model="clientDialog.data.autoforward_enabled"
|
||||
label="Auto-forward DCA to external LN address"
|
||||
class="q-mb-md"></q-toggle>
|
||||
|
||||
<q-input v-if="clientDialog.data.autoforward_enabled"
|
||||
v-model="clientDialog.data.autoforward_ln_address"
|
||||
label="LN address (e.g. user@walletofsatoshi.com)"
|
||||
hint="LP-controlled; failures leave sats safely in LP's LNbits wallet"
|
||||
hint="Operator-facing label only; doesn't affect distribution"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
|
||||
<q-select v-if="clientDialog.mode === 'edit'"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue