Onboarding backend

This commit is contained in:
myrmidex 2025-08-01 00:36:05 +02:00
parent 2adb690d28
commit 17b7ea4aea
9 changed files with 287 additions and 20 deletions

View file

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Models\Asset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AssetController extends Controller
{
public function index(): JsonResponse
{
$assets = Asset::orderBy('symbol')->get();
return response()->json($assets);
}
public function current(): JsonResponse
{
$user = Auth::user();
$asset = $user->asset;
return response()->json([
'asset' => $asset,
]);
}
public function setCurrent(Request $request): JsonResponse
{
$validated = $request->validate([
'symbol' => 'required|string|max:10',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::findOrCreateBySymbol(
$validated['symbol'],
$validated['full_name'] ?? null
);
$user = Auth::user();
$user->update(['asset_id' => $asset->id]);
return response()->json([
'success' => true,
'message' => 'Asset set successfully!',
'asset' => $asset,
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'symbol' => 'required|string|max:10|unique:assets,symbol',
'full_name' => 'nullable|string|max:255',
]);
$asset = Asset::create([
'symbol' => strtoupper($validated['symbol']),
'full_name' => $validated['full_name'],
]);
return response()->json([
'success' => true,
'message' => 'Asset created successfully!',
'asset' => $asset,
], 201);
}
public function show(Asset $asset): JsonResponse
{
$asset->load('assetPrices');
$currentPrice = $asset->currentPrice();
return response()->json([
'asset' => $asset,
'current_price' => $currentPrice,
]);
}
public function search(Request $request): JsonResponse
{
$query = $request->get('q');
if (!$query) {
return response()->json([]);
}
$assets = Asset::where('symbol', 'like', "%{$query}%")
->orWhere('full_name', 'like', "%{$query}%")
->orderBy('symbol')
->limit(10)
->get();
return response()->json($assets);
}
}

View file

@ -11,7 +11,10 @@ class PricingController extends Controller
{
public function current(): JsonResponse
{
$price = AssetPrice::current();
$user = auth()->user();
$assetId = $user->asset_id;
$price = AssetPrice::current($assetId);
return response()->json([
'current_price' => $price,
@ -25,22 +28,34 @@ public function update(Request $request)
'price' => 'required|numeric|min:0.0001',
]);
$assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']);
$user = auth()->user();
if (!$user->asset_id) {
return back()->withErrors(['asset' => 'Please set an asset first.']);
}
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
return back()->with('success', 'Asset price updated successfully!');
}
public function history(Request $request): JsonResponse
{
$user = auth()->user();
$assetId = $user->asset_id;
$limit = $request->get('limit', 30);
$history = AssetPrice::history($limit);
$history = AssetPrice::history($assetId, $limit);
return response()->json($history);
}
public function forDate(Request $request, string $date): JsonResponse
{
$price = AssetPrice::forDate($date);
$user = auth()->user();
$assetId = $user->asset_id;
$price = AssetPrice::forDate($date, $assetId);
return response()->json([
'date' => $date,

67
app/Models/Asset.php Normal file
View file

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @method static create(array $array)
* @method static where(string $string, string $value)
* @method static find(int $id)
* @method static orderBy(string $string)
* @property int $id
* @property string $symbol
* @property string|null $full_name
*/
class Asset extends Model
{
use HasFactory;
protected $fillable = [
'symbol',
'full_name',
];
protected $casts = [
'symbol' => 'string',
'full_name' => 'string',
];
public function assetPrices(): HasMany
{
return $this->hasMany(Pricing\AssetPrice::class);
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function currentPrice(): ?float
{
$latestPrice = $this->assetPrices()->latest('date')->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function findBySymbol(string $symbol): ?self
{
return static::where('symbol', strtoupper($symbol))->first();
}
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
{
$asset = static::findBySymbol($symbol);
if (! $asset) {
$asset = static::create([
'symbol' => strtoupper($symbol),
'full_name' => $fullName,
]);
}
return $asset;
}
}

View file

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
@ -20,6 +21,7 @@ class AssetPrice extends Model
use HasFactory;
protected $fillable = [
'asset_id',
'date',
'price',
];
@ -29,32 +31,54 @@ class AssetPrice extends Model
'price' => 'decimal:4',
];
public static function current(): ?float
public function asset(): BelongsTo
{
$latestPrice = static::latest('date')->first();
return $this->belongsTo(\App\Models\Asset::class);
}
public static function current(int $assetId = null): ?float
{
$query = static::latest('date');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$latestPrice = $query->first();
return $latestPrice ? $latestPrice->price : null;
}
public static function forDate(string $date): ?float
public static function forDate(string $date, int $assetId = null): ?float
{
$price = static::where('date', '<=', $date)
->orderBy('date', 'desc')
->first();
$query = static::where('date', '<=', $date)
->orderBy('date', 'desc');
if ($assetId) {
$query->where('asset_id', $assetId);
}
$price = $query->first();
return $price ? $price->price : null;
}
public static function updatePrice(string $date, float $price): self
public static function updatePrice(int $assetId, string $date, float $price): self
{
return static::updateOrCreate(
['date' => $date],
['asset_id' => $assetId, 'date' => $date],
['price' => $price]
);
}
public static function history(int $limit = 30): Collection
public static function history(int $assetId = null, int $limit = 30): Collection
{
return static::orderBy('date', 'desc')->limit($limit)->get();
$query = static::orderBy('date', 'desc')->limit($limit);
if ($assetId) {
$query->where('asset_id', $assetId);
}
return $query->get();
}
}

View file

@ -4,9 +4,13 @@
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/**
* @property int $asset_id
*/
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
@ -21,6 +25,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'asset_id',
];
/**
@ -33,11 +38,6 @@ class User extends Authenticatable
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@ -45,4 +45,27 @@ protected function casts(): array
'password' => 'hashed',
];
}
public function asset(): BelongsTo
{
return $this->belongsTo(Asset::class);
}
public function hasCompletedOnboarding(): bool
{
// Check if user has asset, purchases, and milestones
return $this->asset_id !== null
&& $this->hasPurchases()
&& $this->hasMilestones();
}
public function hasPurchases(): bool
{
return \App\Models\Transactions\Purchase::totalShares() > 0;
}
public function hasMilestones(): bool
{
return \App\Models\Milestone::count() > 0;
}
}

View file

@ -17,8 +17,11 @@ public function up(): void
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
$table->rememberToken();
$table->timestamps();
$table->index('asset_id');
});
Schema::create('password_reset_tokens', function (Blueprint $table) {

View file

@ -10,11 +10,13 @@ public function up(): void
{
Schema::create('asset_prices', function (Blueprint $table) {
$table->id();
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
$table->date('date');
$table->decimal('price', 10, 4);
$table->timestamps();
$table->unique('date');
$table->unique(['asset_id', 'date']);
$table->index('asset_id');
$table->index('date');
});
}

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('assets', function (Blueprint $table) {
$table->id();
$table->string('symbol')->unique();
$table->string('full_name')->nullable();
$table->timestamps();
$table->index('symbol');
});
}
public function down(): void
{
Schema::dropIfExists('assets');
}
};

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\AssetController;
use App\Http\Controllers\Transactions\PurchaseController;
use App\Http\Controllers\Pricing\PricingController;
use App\Http\Controllers\Milestones\MilestoneController;
@ -14,6 +15,16 @@
return Inertia::render('dashboard');
})->name('dashboard');
// Asset routes
Route::prefix('assets')->name('assets.')->group(function () {
Route::get('/', [AssetController::class, 'index'])->name('index');
Route::post('/', [AssetController::class, 'store'])->name('store');
Route::get('/current', [AssetController::class, 'current'])->name('current');
Route::post('/set-current', [AssetController::class, 'setCurrent'])->name('set-current');
Route::get('/search', [AssetController::class, 'search'])->name('search');
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
});
// Purchase routes
Route::prefix('purchases')->name('purchases.')->group(function () {
Route::get('/', [PurchaseController::class, 'index'])->name('index');