Add client registration functionality: Implement API endpoint for client self-registration, including validation and error handling. Update frontend to support registration form and status checks, enhancing user experience for DCA clients.

This commit is contained in:
padreug 2025-06-26 14:07:22 +02:00
parent c86d650e5a
commit 315bcae4ca
5 changed files with 338 additions and 12 deletions

70
crud.py
View file

@ -12,6 +12,7 @@ from .models import (
ClientTransaction, ClientTransaction,
ClientAnalytics, ClientAnalytics,
UpdateClientSettings, UpdateClientSettings,
ClientRegistrationData,
) )
# Connect to admin extension's database # Connect to admin extension's database
@ -446,4 +447,71 @@ async def update_client_dca_settings(client_id: str, settings: UpdateClientSetti
) )
return True return True
except Exception: except Exception:
return False return False
###################################################
############## CLIENT REGISTRATION ###############
###################################################
async def register_dca_client(user_id: str, wallet_id: str, registration_data: ClientRegistrationData) -> Optional[dict]:
"""Register a new DCA client - special permission for self-registration"""
from lnbits.helpers import urlsafe_short_hash
from lnbits.core.crud import get_user
try:
# Verify user exists and get username
user = await get_user(user_id)
username = registration_data.username or (user.username if user else f"user_{user_id[:8]}")
# Check if client already exists
existing_client = await db.fetchone(
"SELECT id FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
if existing_client:
return {"error": "Client already registered", "client_id": existing_client[0]}
# Create new client
client_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO satoshimachine.dca_clients
(id, user_id, wallet_id, username, dca_mode, fixed_mode_daily_limit, status, created_at, updated_at)
VALUES (:id, :user_id, :wallet_id, :username, :dca_mode, :fixed_mode_daily_limit, :status, :created_at, :updated_at)
""",
{
"id": client_id,
"user_id": user_id,
"wallet_id": wallet_id,
"username": username,
"dca_mode": registration_data.dca_mode,
"fixed_mode_daily_limit": registration_data.fixed_mode_daily_limit,
"status": "active",
"created_at": datetime.now(),
"updated_at": datetime.now()
}
)
return {
"success": True,
"client_id": client_id,
"message": f"DCA client registered successfully with {registration_data.dca_mode} mode"
}
except Exception as e:
print(f"Error registering DCA client: {e}")
return {"error": f"Registration failed: {str(e)}"}
async def get_client_by_user_id(user_id: str) -> Optional[dict]:
"""Get client by user_id - returns dict instead of model for easier access"""
try:
client = await db.fetchone(
"SELECT * FROM satoshimachine.dca_clients WHERE user_id = :user_id",
{"user_id": user_id}
)
return dict(client) if client else None
except Exception:
return None

View file

@ -61,3 +61,10 @@ class UpdateClientSettings(BaseModel):
status: Optional[str] = None # 'active' or 'inactive' status: Optional[str] = None # 'active' or 'inactive'
class ClientRegistrationData(BaseModel):
"""Data for client self-registration"""
dca_mode: str = "flow" # Default to flow mode
fixed_mode_daily_limit: Optional[int] = None
username: Optional[str] = None

View file

@ -4,6 +4,18 @@ window.app = Vue.createApp({
delimiters: ['${', '}'], delimiters: ['${', '}'],
data: function () { data: function () {
return { return {
// Registration state
isRegistered: false,
registrationChecked: false,
showRegistrationDialog: false,
registrationForm: {
selectedWallet: null,
dca_mode: 'flow',
fixed_mode_daily_limit: null,
username: ''
},
// Dashboard state
dashboardData: null, dashboardData: null,
transactions: [], transactions: [],
loading: true, loading: true,
@ -60,6 +72,73 @@ window.app = Vue.createApp({
}, },
methods: { methods: {
// Registration Methods
async checkRegistrationStatus() {
try {
const { data } = await LNbits.api.request(
'GET',
'/satmachineclient/api/v1/registration-status',
this.g.user.wallets[0].adminkey
)
this.isRegistered = data.is_registered
this.registrationChecked = true
if (!this.isRegistered) {
this.showRegistrationDialog = true
// Pre-fill username and default wallet if available
this.registrationForm.username = this.g.user.username || ''
this.registrationForm.selectedWallet = this.g.user.wallets[0] || null
}
return data
} catch (error) {
console.error('Error checking registration status:', error)
this.error = 'Failed to check registration status'
this.registrationChecked = true
}
},
async registerClient() {
try {
// Prepare registration data similar to the admin test client creation
const registrationData = {
dca_mode: this.registrationForm.dca_mode,
fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit,
username: this.registrationForm.username || this.g.user.username || `user_${this.g.user.id.substring(0, 8)}`
}
const { data } = await LNbits.api.request(
'POST',
'/satmachineclient/api/v1/register',
this.registrationForm.selectedWallet.adminkey,
registrationData
)
this.isRegistered = true
this.showRegistrationDialog = false
this.$q.notify({
type: 'positive',
message: data.message || 'Successfully registered for DCA!',
icon: 'check_circle',
position: 'top'
})
// Load dashboard data after successful registration
await this.loadDashboardData()
} catch (error) {
console.error('Error registering client:', error)
this.$q.notify({
type: 'negative',
message: error.detail || 'Failed to register for DCA',
position: 'top'
})
}
},
// Dashboard Methods
formatCurrency(amount) { formatCurrency(amount) {
if (!amount) return 'Q 0.00'; if (!amount) return 'Q 0.00';
// Values are already in full currency units, not centavos // Values are already in full currency units, not centavos
@ -662,11 +741,18 @@ window.app = Vue.createApp({
async created() { async created() {
try { try {
this.loading = true this.loading = true
await Promise.all([
this.loadDashboardData(), // Check registration status first
this.loadTransactions(), await this.checkRegistrationStatus()
this.loadChartData()
]) // Only load dashboard data if registered
if (this.isRegistered) {
await Promise.all([
this.loadDashboardData(),
this.loadTransactions(),
this.loadChartData()
])
}
} catch (error) { } catch (error) {
console.error('Error initializing dashboard:', error) console.error('Error initializing dashboard:', error)
this.error = 'Failed to initialize dashboard' this.error = 'Failed to initialize dashboard'
@ -694,7 +780,7 @@ window.app = Vue.createApp({
computed: { computed: {
hasData() { hasData() {
return this.dashboardData && !this.loading return this.dashboardData && !this.loading && this.isRegistered
} }
}, },

View file

@ -11,7 +11,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<!-- Loading State --> <!-- Loading State -->
<q-card v-if="loading"> <q-card v-if="loading || !registrationChecked">
<q-card-section class="text-center"> <q-card-section class="text-center">
<q-spinner size="2em" /> <q-spinner size="2em" />
<div class="q-mt-md">Loading your DCA dashboard...</div> <div class="q-mt-md">Loading your DCA dashboard...</div>
@ -26,8 +26,19 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
<!-- Not Registered State -->
<q-card v-if="registrationChecked && !isRegistered && !loading" class="bg-orange-1">
<q-card-section class="text-center">
<q-icon name="account_circle" size="3em" color="orange" />
<div class="text-h6 q-mt-md text-orange-8">Welcome to Bitcoin DCA!</div>
<div class="text-body2 text-grey-7 q-mt-sm">
Please complete your registration to start your Dollar Cost Averaging journey.
</div>
</q-card-section>
</q-card>
<!-- Dashboard Content --> <!-- Dashboard Content -->
<div v-if="hasData"> <div v-if="hasData && isRegistered">
<!-- Hero Card - Bitcoin Stack Progress --> <!-- Hero Card - Bitcoin Stack Progress -->
<q-card class="q-mb-md bg-gradient-to-r from-orange-1 to-orange-2" style="background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);"> <q-card class="q-mb-md bg-gradient-to-r from-orange-1 to-orange-2" style="background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);">
<q-card-section class="text-center"> <q-card-section class="text-center">
@ -233,8 +244,8 @@
</div> </div>
<!-- DCA Performance Chart - Always render to ensure canvas is available --> <!-- DCA Performance Chart - Only show when registered -->
<q-card class="q-mb-md"> <q-card v-if="isRegistered" class="q-mb-md">
<q-card-section> <q-card-section>
<h6 class="text-subtitle2 q-my-none q-mb-md">Bitcoin Accumulation Progress</h6> <h6 class="text-subtitle2 q-my-none q-mb-md">Bitcoin Accumulation Progress</h6>
<div class="chart-container" style="position: relative; height: 300px;"> <div class="chart-container" style="position: relative; height: 300px;">
@ -262,7 +273,7 @@
</q-card> </q-card>
<!-- Dashboard Content --> <!-- Dashboard Content -->
<div v-if="hasData"> <div v-if="hasData && isRegistered">
<!-- Transaction History --> <!-- Transaction History -->
<q-card> <q-card>
@ -511,5 +522,107 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<!-- Registration Dialog -->
<q-dialog v-model="showRegistrationDialog" persistent position="top">
<q-card class="q-pa-lg" style="width: 500px; max-width: 90vw">
<div class="text-center q-mb-lg">
<q-icon name="account_circle" size="4em" color="orange" />
<div class="text-h5 q-mt-md text-orange-8">Welcome to DCA!</div>
<div class="text-body2 text-grey-7">Let's set up your Bitcoin Dollar Cost Averaging account</div>
</div>
<q-form @submit="registerClient" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="registrationForm.username"
label="Username (Optional)"
placeholder="Enter a friendly name"
hint="How you'd like to be identified in the system"
/>
<q-select
filled
dense
v-model="registrationForm.selectedWallet"
:options="g.user.wallets"
option-label="name"
label="DCA Wallet *"
hint="Choose which wallet will receive your Bitcoin DCA purchases"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>
${scope.opt.name} (ID: ${scope.opt.id.substring(0, 8)}... • ${Math.round(scope.opt.balance_msat / 1000)} sats)
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-select
filled
dense
v-model="registrationForm.dca_mode"
:options="[
{ label: 'Flow Mode (Recommended)', value: 'flow', description: 'Proportional distribution based on your balance' },
{ label: 'Fixed Mode', value: 'fixed', description: 'Set daily purchase limits' }
]"
option-label="label"
option-value="value"
label="DCA Strategy *"
hint="Choose how your Bitcoin purchases will be distributed"
>
<template v-slot:option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<q-item-label>${scope.opt.label} - ${scope.opt.description}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
v-if="registrationForm.dca_mode === 'fixed'"
filled
dense
type="number"
v-model.number="registrationForm.fixed_mode_daily_limit"
label="Daily Limit (GTQ)"
placeholder="Enter daily purchase limit in centavos"
hint="Maximum amount to purchase per day (in centavos: 1 GTQ = 100 centavos)"
:rules="[
val => registrationForm.dca_mode !== 'fixed' || (val && val > 0) || 'Daily limit is required for fixed mode'
]"
/>
<q-banner class="bg-blue-1 text-blue-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="info" color="blue" />
</template>
<div class="text-caption">
<strong>Flow Mode:</strong> Your Bitcoin purchases are proportional to your deposit balance.<br>
<strong>Fixed Mode:</strong> Set a daily limit for consistent Bitcoin accumulation.
</div>
</q-banner>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
type="submit"
:disable="!registrationForm.selectedWallet || !registrationForm.dca_mode"
class="full-width"
size="lg"
>
<q-icon name="rocket_launch" class="q-mr-sm" />
Start My DCA Journey
</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -15,17 +15,69 @@ from .crud import (
get_client_analytics, get_client_analytics,
update_client_dca_settings, update_client_dca_settings,
get_client_by_user_id, get_client_by_user_id,
register_dca_client,
) )
from .models import ( from .models import (
ClientDashboardSummary, ClientDashboardSummary,
ClientTransaction, ClientTransaction,
ClientAnalytics, ClientAnalytics,
UpdateClientSettings, UpdateClientSettings,
ClientRegistrationData,
) )
satmachineclient_api_router = APIRouter() satmachineclient_api_router = APIRouter()
###################################################
############## CLIENT REGISTRATION ###############
###################################################
@satmachineclient_api_router.post("/api/v1/register", status_code=HTTPStatus.CREATED)
async def api_register_client(
registration_data: ClientRegistrationData,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Register a new DCA client
Clients can self-register using their wallet admin key.
Creates a new client entry in the satoshimachine database.
"""
result = await register_dca_client(
wallet.wallet.user,
wallet.wallet.id,
registration_data
)
if "error" in result:
if "already registered" in result["error"]:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=result["error"]
)
else:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=result["error"]
)
return result
@satmachineclient_api_router.get("/api/v1/registration-status")
async def api_check_registration_status(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Check if user is already registered as a DCA client"""
client = await get_client_by_user_id(wallet.wallet.user)
return {
"is_registered": client is not None,
"client_id": client["id"] if client else None,
"dca_mode": client["dca_mode"] if client else None,
"status": client["status"] if client else None,
}
################################################### ###################################################
############## CLIENT DASHBOARD API ############### ############## CLIENT DASHBOARD API ###############
################################################### ###################################################