diff --git a/app/Http/Controllers/AssetController.php b/app/Http/Controllers/AssetController.php new file mode 100644 index 0000000..3880b94 --- /dev/null +++ b/app/Http/Controllers/AssetController.php @@ -0,0 +1,97 @@ +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); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Pricing/PricingController.php b/app/Http/Controllers/Pricing/PricingController.php index bb07156..8dba55f 100644 --- a/app/Http/Controllers/Pricing/PricingController.php +++ b/app/Http/Controllers/Pricing/PricingController.php @@ -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, diff --git a/app/Models/Asset.php b/app/Models/Asset.php new file mode 100644 index 0000000..88fe97a --- /dev/null +++ b/app/Models/Asset.php @@ -0,0 +1,67 @@ + '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; + } +} diff --git a/app/Models/Pricing/AssetPrice.php b/app/Models/Pricing/AssetPrice.php index 2d3b6cf..e3f5d04 100644 --- a/app/Models/Pricing/AssetPrice.php +++ b/app/Models/Pricing/AssetPrice.php @@ -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(); } } diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..e8d3f00 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 - */ 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; + } } diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 05fb5d9..9cb9538 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -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) { diff --git a/database/migrations/2025_07_10_152716_create_asset_prices_table.php b/database/migrations/2025_07_10_152716_create_asset_prices_table.php index 94ebab3..36653db 100644 --- a/database/migrations/2025_07_10_152716_create_asset_prices_table.php +++ b/database/migrations/2025_07_10_152716_create_asset_prices_table.php @@ -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'); }); } diff --git a/database/migrations/2025_07_31_222423_create_assets_table.php b/database/migrations/2025_07_31_222423_create_assets_table.php new file mode 100644 index 0000000..a27ba44 --- /dev/null +++ b/database/migrations/2025_07_31_222423_create_assets_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('symbol')->unique(); + $table->string('full_name')->nullable(); + $table->timestamps(); + + $table->index('symbol'); + }); + } + + public function down(): void + { + Schema::dropIfExists('assets'); + } +}; diff --git a/routes/web.php b/routes/web.php index 3ee6392..9cb1e25 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ 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');