diff --git a/README.md b/README.md index bbbaad6..623183f 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ $ ./sail up -d ## Planned - [ ] IFTTT inspired Dynamic Automations - - [ ] Plaid integration for asset syncing - - [ ] Budgeting per project + - [x] Plaid integration for asset syncing + - [-] Budgeting per project - [ ] Built-in project researching for Scholarly articles and browsing the web. - [ ] Calendar integration for project and site wide events - [ ] Task management, tasks are created per project @@ -37,27 +37,3 @@ $ ./sail up -d - Single Chat interface - Email interface -# Domain Feature Details -This presently uses Cloudflare DNS. - -We can configure and manage any DNS records that Cloudflare supports. - -We also want to be able to update the NS of registrars to point to Cloudflare - -## Domain Syncing -This will sync domains from Namecheap to Cloudflare. It will also sync domains from Cloudflare to Laravel Forge. - -# Server Feature Details -We can manage any server listed in our database as long as there is at least an SSH server configured. - -Servers house code or perform jobs. They are not necessarily web servers, but can be. When accessed via SSH, you have full access to everything that user has access to. - -## Server Feature Details -Laravel Forge, and Digital Ocean are both supported providers, but any server can be added manually and accessed via SSH. - -# RSS Feature Details -RSS feeds are synced and updated on a schedule. This is done via a job that runs every 15 minutes. - -# Page Feature Details -Pages are dynamic routes that can be configured to point to any domain, or server. They can also be configured to redirect to another page, or domain. - diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 86452d1..cf24ee5 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,6 +4,7 @@ namespace App\Actions\Fortify; +use App\Models\Person; use App\Models\Team; use App\Models\User; use Illuminate\Support\Facades\DB; @@ -37,6 +38,7 @@ public function create(array $input): User 'password' => Hash::make($input['password']), ]), function (User $user) { $this->createTeam($user); + $this->createPersonalRecord($user); }); }); } @@ -52,4 +54,13 @@ protected function createTeam(User $user): void 'personal_team' => true, ])); } + + protected function createPersonalRecord(User $user) + { + Person::create([ + 'name' => $user->name, + 'emails' => [$user->email], + 'names' => [$user->name], + ]); + } } diff --git a/app/Actions/Spork/SyncDataFromCredential.php b/app/Actions/Spork/SyncDataFromCredential.php index 9bb9f84..194715d 100644 --- a/app/Actions/Spork/SyncDataFromCredential.php +++ b/app/Actions/Spork/SyncDataFromCredential.php @@ -4,11 +4,7 @@ namespace App\Actions\Spork; -use App\Jobs\FetchDomainsForCredential; -use App\Jobs\FetchRegistrarForCredential; -use App\Jobs\FetchServersForCredential; -use App\Jobs\Finance\SyncPlaidTransactionsJob; -use App\Jobs\Servers\LaravelForgeServersSyncJob; +use App\Jobs\FetchResourcesFromCredential; use App\Models\Credential; use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Http\Request; @@ -25,13 +21,7 @@ public function __invoke(Dispatcher $dispatcher, Request $request) $credentials = Credential::where('user_id', $request->user()->id)->whereIn('id', $request->get('items'))->get(); foreach ($credentials as $credential) { - $dispatcher->dispatch(match ($credential->type) { - Credential::TYPE_REGISTRAR => new FetchRegistrarForCredential($credential), - Credential::TYPE_DOMAIN => new FetchDomainsForCredential($credential), - Credential::TYPE_SERVER => new FetchServersForCredential($credential), - Credential::TYPE_DEVELOPMENT, 'forge' => new LaravelForgeServersSyncJob($credential), - Credential::TYPE_FINANCE => new SyncPlaidTransactionsJob($credential, now()->subWeek(), now(), false), - }); + $dispatcher->dispatch(new FetchResourcesFromCredential($credential)); } } } diff --git a/app/Console/Commands/MakeUser.php b/app/Console/Commands/MakeUser.php index fc567c7..4f94b92 100644 --- a/app/Console/Commands/MakeUser.php +++ b/app/Console/Commands/MakeUser.php @@ -4,7 +4,7 @@ namespace App\Console\Commands; -use App\Models\User; +use App\Actions\Fortify\CreateNewUser; use Illuminate\Console\Command; class MakeUser extends Command @@ -28,16 +28,14 @@ class MakeUser extends Command */ public function handle() { - $user = User::create([ + $action = new CreateNewUser(); + + $action->create([ 'name' => $this->ask('What is your name?'), 'email' => $this->ask('What is your email address?'), - 'password' => bcrypt($this->ask('What password would you like to use?')), - ]); - - $user->ownedTeams()->create([ - 'name' => config('app.name'), - 'personal_team' => true, - 'settings' => [], + 'password' => $password = $this->ask('What password would you like to use?'), + 'password_confirmation' => $password, + 'terms' => true, ]); } } diff --git a/app/Console/Commands/Messaging/MatrixBeeperRequestCode.php b/app/Console/Commands/Messaging/MatrixBeeperRequestCode.php index b0469c6..eb1394b 100644 --- a/app/Console/Commands/Messaging/MatrixBeeperRequestCode.php +++ b/app/Console/Commands/Messaging/MatrixBeeperRequestCode.php @@ -27,7 +27,7 @@ class MatrixBeeperRequestCode extends Command */ public function handle() { - $client = new \App\Services\Matrix\MatrixClient($this->argument('email'), $this->option('host')); + $client = new \App\Services\Messaging\MatrixClient($this->argument('email'), $this->option('host')); $client->requestCodeForBeeper($this->argument('email')); $this->info('Please check your email, and return within 30 minutes'); diff --git a/app/Console/Commands/Messaging/MatrixBeeperRequestTokenCode.php b/app/Console/Commands/Messaging/MatrixBeeperRequestTokenCode.php index ecc8be5..c8b4262 100644 --- a/app/Console/Commands/Messaging/MatrixBeeperRequestTokenCode.php +++ b/app/Console/Commands/Messaging/MatrixBeeperRequestTokenCode.php @@ -27,7 +27,7 @@ class MatrixBeeperRequestTokenCode extends Command */ public function handle() { - $client = new \App\Services\Matrix\MatrixClient($this->argument('email'), $this->option('host')); + $client = new \App\Services\Messaging\MatrixClient($this->argument('email'), $this->option('host')); $response = $client->loginWithJwt($this->argument('code')); diff --git a/app/Console/Commands/Messaging/MatrixBeeperVerifyCode.php b/app/Console/Commands/Messaging/MatrixBeeperVerifyCode.php index d6865ba..7051114 100644 --- a/app/Console/Commands/Messaging/MatrixBeeperVerifyCode.php +++ b/app/Console/Commands/Messaging/MatrixBeeperVerifyCode.php @@ -27,7 +27,7 @@ class MatrixBeeperVerifyCode extends Command */ public function handle() { - $client = new \App\Services\Matrix\MatrixClient($this->argument('email'), $this->option('host')); + $client = new \App\Services\Messaging\MatrixClient($this->argument('email'), $this->option('host')); $response = $client->loginWithBeeperCode($this->argument('email'), $this->argument('code')); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 410c28f..996dc55 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -18,7 +18,7 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule): void { $schedule->job(UpdateAllFeeds::class)->hourly(); - $schedule->job(FetchResourcesFromCredentials::class)->everyOddHour(); + $schedule->job(FetchResourcesFromCredentials::class)->hourly(); $schedule->job(FetchCloudflareAnalytics::class)->everyFourHours(); $schedule->command('operations:queue')->everyFiveMinutes(); } diff --git a/app/Contracts/LogicalOperator.php b/app/Contracts/LogicalOperator.php index 90d352d..f0283ef 100644 --- a/app/Contracts/LogicalOperator.php +++ b/app/Contracts/LogicalOperator.php @@ -6,4 +6,5 @@ interface LogicalOperator { + public function compute(mixed $haystack, mixed $needle): bool; } diff --git a/app/Contracts/Repositories/CredentialRepositoryContract.php b/app/Contracts/Repositories/CredentialRepositoryContract.php new file mode 100644 index 0000000..797db7a --- /dev/null +++ b/app/Contracts/Repositories/CredentialRepositoryContract.php @@ -0,0 +1,12 @@ +model->load('credential'); + + return [ + new PrivateChannel('App.Models.User.'.$this->model->credential->user_id), + ]; + } } diff --git a/app/Http/Controllers/Api/Mail/MarkAsReadController.php b/app/Http/Controllers/Api/Mail/MarkAsReadController.php index dcc4c79..488f055 100644 --- a/app/Http/Controllers/Api/Mail/MarkAsReadController.php +++ b/app/Http/Controllers/Api/Mail/MarkAsReadController.php @@ -5,16 +5,26 @@ namespace App\Http\Controllers\Api\Mail; use App\Http\Controllers\Controller; -use App\Services\ImapService; +use App\Http\Requests\Messages\MailOwnerRequest; +use App\Models\Message; +use App\Services\Messaging\ImapFactoryService; class MarkAsReadController extends Controller { - public function __invoke(ImapService $imap) + public function __invoke(MailOwnerRequest $request, ImapFactoryService $factoryService) { request()->validate([ 'id' => 'integer', ]); - $imap->markAsRead(request('id')); + $message = Message::query() + ->with('credential') + ->findOrFail($request->get('id')); + + $service = $factoryService->make($message->credential); + $service->markAsRead($message->event_id); + $message->update([ + 'seen' => true, + ]); } } diff --git a/app/Http/Controllers/Api/Mail/MarkAsUnreadController.php b/app/Http/Controllers/Api/Mail/MarkAsUnreadController.php index 334c5ef..f84120d 100644 --- a/app/Http/Controllers/Api/Mail/MarkAsUnreadController.php +++ b/app/Http/Controllers/Api/Mail/MarkAsUnreadController.php @@ -5,16 +5,26 @@ namespace App\Http\Controllers\Api\Mail; use App\Http\Controllers\Controller; -use App\Services\ImapService; +use App\Http\Requests\Messages\MailOwnerRequest; +use App\Models\Message; +use App\Services\Messaging\ImapFactoryService; class MarkAsUnreadController extends Controller { - public function __invoke(ImapService $imap) + public function __invoke(MailOwnerRequest $request, ImapFactoryService $factoryService) { request()->validate([ 'id' => 'integer', ]); - $imap->markAsUnread(request('id')); + $message = Message::query() + ->with('credential') + ->findOrFail($request->get('id')); + + $service = $factoryService->make($message->credential); + $service->markAsUnread($message->event_id); + $message->update([ + 'seen' => false, + ]); } } diff --git a/app/Http/Controllers/Petoskey/TodayController.php b/app/Http/Controllers/Petoskey/TodayController.php index 8cb1e63..4e14979 100644 --- a/app/Http/Controllers/Petoskey/TodayController.php +++ b/app/Http/Controllers/Petoskey/TodayController.php @@ -28,30 +28,5 @@ public function __invoke() ->orderByDesc('last_modified') ->paginate(10, ['*'], 'articles'), ]); - - return view('petoskey.today', [ - 'articles' => Article::query() - ->with('author:id,name') - ->where('author_type', ExternalRssFeed::class) - ->whereIn('author_id', ExternalRssFeed::query() - ->where('name', 'Petoskey Area') - ->orWhere('name', 'Petoskey Downtown on Facebook') - ->orWhere('name', 'Petoskey Library on Facebook') - ->pluck('id') - ) - ->where('last_modified', '>=', now()->subDays(7)) - ->orderByDesc('last_modified') - ->paginate(5, ['*'], 'articles'), - 'weather' => Arr::first((new OpenWeatherService)->query('Petoskey, MI')), - 'news' => Article::query() - ->with('author:id,name') - ->where('author_type', ExternalRssFeed::class) - ->where('last_modified', '>=', now()->subDays(7)) - ->whereIn('author_id', ExternalRssFeed::query() - ->where('name', 'Petoskey News on Facebook') - ->pluck('id') - ) - ->paginate(5, ['*'], 'news'), - ]); } } diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php new file mode 100644 index 0000000..c3db302 --- /dev/null +++ b/app/Http/Controllers/TaskController.php @@ -0,0 +1,68 @@ + $request->user() ? + $request->user()->messages() + ->where('messages.type', 'email') + ->where('seen', false) + ->count() + : 0, + 'notifications' => $request->user()?->notifications ?? [], ]); } } diff --git a/app/Http/Requests/Messages/MailOwnerRequest.php b/app/Http/Requests/Messages/MailOwnerRequest.php new file mode 100644 index 0000000..b748da0 --- /dev/null +++ b/app/Http/Requests/Messages/MailOwnerRequest.php @@ -0,0 +1,26 @@ +get('id'); + $message = Message::findOrFail($messageId); + + return $this->user()->credentials()->where('id', $message->credential_id)->exists(); + } + + public function rules(): array + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/StoreTaskRequest.php b/app/Http/Requests/StoreTaskRequest.php new file mode 100644 index 0000000..38dc6ca --- /dev/null +++ b/app/Http/Requests/StoreTaskRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + // + ]; + } +} diff --git a/app/Http/Requests/UpdateTaskRequest.php b/app/Http/Requests/UpdateTaskRequest.php new file mode 100644 index 0000000..56487ae --- /dev/null +++ b/app/Http/Requests/UpdateTaskRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + // + ]; + } +} diff --git a/app/Jobs/FetchDomainsForCredential.php b/app/Jobs/FetchDomainsForCredential.php index 23eb71f..ec1befc 100644 --- a/app/Jobs/FetchDomainsForCredential.php +++ b/app/Jobs/FetchDomainsForCredential.php @@ -45,8 +45,8 @@ public function handle(Dispatcher $dispatcher) return; } - $dispatcher->dispatchSync(match ($this->credential->service) { + $this->batch()->add([match ($this->credential->service) { Credential::CLOUDFLARE => new CloudflareSyncAndPurgeJob($this->credential, $this->user), - }); + }]); } } diff --git a/app/Jobs/FetchRegistrarForCredential.php b/app/Jobs/FetchRegistrarForCredential.php index 51dd349..48e3e96 100644 --- a/app/Jobs/FetchRegistrarForCredential.php +++ b/app/Jobs/FetchRegistrarForCredential.php @@ -39,15 +39,18 @@ public function __construct( */ public function handle(Dispatcher $dispatcher) { + if ($this->batch()?->cancelled()) { + return; + } if ($this->credential->type !== Credential::TYPE_REGISTRAR) { info('Credential is not of registrar type.'); return; } - $dispatcher->dispatchSync(match ($this->credential->service) { + $this->batch()->add([match ($this->credential->service) { Credential::NAMECHEAP => new NamecheapSyncJob($this->credential, $this->user), Credential::CLOUDFLARE => new CloudflareSyncJob($this->credential, $this->user), - }); + }]); } } diff --git a/app/Jobs/FetchResourcesFromCredential.php b/app/Jobs/FetchResourcesFromCredential.php new file mode 100644 index 0000000..3f03935 --- /dev/null +++ b/app/Jobs/FetchResourcesFromCredential.php @@ -0,0 +1,56 @@ +batch()?->cancelled()) { + return; + } + + $this->batch()->add([ + match ($this->credential->type) { + Credential::TYPE_REGISTRAR => new FetchRegistrarForCredential($this->credential), + Credential::TYPE_DOMAIN => new FetchDomainsForCredential($this->credential), + Credential::TYPE_SERVER => new FetchServersForCredential($this->credential), + Credential::TYPE_DEVELOPMENT, 'forge' => new LaravelForgeServersSyncJob($this->credential), + Credential::TYPE_FINANCE => new SyncPlaidTransactionsJob($this->credential, now()->subWeek(), now(), false), + Credential::TYPE_EMAIL => new SyncMailboxIfCredentialsAreSet($this->credential), + default => Log::error(sprintf('Found unsupported credential type for FetchResourcesFromCredentialsJob: %s', $credential->type), []), + }, + ]); + } +} diff --git a/app/Jobs/FetchResourcesFromCredentials.php b/app/Jobs/FetchResourcesFromCredentials.php index 50122f0..ed21fc8 100644 --- a/app/Jobs/FetchResourcesFromCredentials.php +++ b/app/Jobs/FetchResourcesFromCredentials.php @@ -4,16 +4,15 @@ namespace App\Jobs; -use App\Jobs\Finance\SyncPlaidTransactionsJob; -use App\Jobs\Servers\LaravelForgeServersSyncJob; use App\Models\Credential; use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Bus\QueueingDispatcher; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection; class FetchResourcesFromCredentials implements ShouldQueue { @@ -25,7 +24,6 @@ class FetchResourcesFromCredentials implements ShouldQueue * @return void */ public function __construct( - ) { } @@ -34,22 +32,17 @@ public function __construct( * * @return void */ - public function handle(Dispatcher $dispatcher) + public function handle(QueueingDispatcher $dispatcher) { $credentials = Credential::all(); - foreach ($credentials as $credential) { - if ($credential->type === 'ssh') { - continue; - } - - $dispatcher->dispatchSync(match ($credential->type) { - Credential::TYPE_REGISTRAR => new FetchRegistrarForCredential($credential), - Credential::TYPE_DOMAIN => new FetchDomainsForCredential($credential), - Credential::TYPE_SERVER => new FetchServersForCredential($credential), - Credential::TYPE_DEVELOPMENT, 'forge' => new LaravelForgeServersSyncJob($credential), - Credential::TYPE_FINANCE => new SyncPlaidTransactionsJob($credential, now()->subWeek(), now(), false), - }); - } + $jobs = $credentials->groupBy('user_id') + ->map(fn (Collection $group) => $group->map(fn ($credential) => new FetchResourcesFromCredential($credential))->toArray() + )->toArray(); + + $dispatcher->batch($jobs) + ->name('Updatch Resources From Credentials') + ->allowFailures() + ->dispatch(); } } diff --git a/app/Jobs/FetchServersForCredential.php b/app/Jobs/FetchServersForCredential.php index 6b05fe2..aa611e7 100644 --- a/app/Jobs/FetchServersForCredential.php +++ b/app/Jobs/FetchServersForCredential.php @@ -38,12 +38,16 @@ public function __construct( */ public function handle(Dispatcher $dispatcher) { + + if ($this->batch()?->cancelled()) { + return; + } if ($this->credential->type !== Credential::TYPE_SERVER) { return; } - $dispatcher->dispatchSync(match ($this->credential->service) { + $this->batch()->add([match ($this->credential->service) { Credential::DIGITAL_OCEAN => new DigitalOceanSyncJob($this->credential, $this->user), - }); + }]); } } diff --git a/app/Jobs/Finance/SyncPlaidTransactionsJob.php b/app/Jobs/Finance/SyncPlaidTransactionsJob.php index 3232ff1..9b72184 100644 --- a/app/Jobs/Finance/SyncPlaidTransactionsJob.php +++ b/app/Jobs/Finance/SyncPlaidTransactionsJob.php @@ -9,6 +9,7 @@ use App\Models\Finance\Account; use App\Models\Finance\Transaction; use Carbon\Carbon; +use Illuminate\Bus\Batchable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -17,29 +18,20 @@ class SyncPlaidTransactionsJob implements ShouldQueue { - use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - private $accessToken; - - private $startDate; - - private $endDate; - - protected $shouldSendAlerts; - - public function __construct(Credential $access, Carbon $startDate, Carbon $endDate, ?bool $shouldSendAlerts = true) - { - $this->accessToken = $access; - $this->startDate = $startDate; - $this->endDate = $endDate; - $this->shouldSendAlerts = $shouldSendAlerts; + public function __construct( + protected Credential $accessToken + ) { } public function handle(PlaidServiceContract $plaid): void { - $transactionsResponse = $plaid->getTransactions($this->accessToken->api_key, $this->startDate, $this->endDate); + if ($this->batch()?->cancelled()) { + return; + } - $accounts = $transactionsResponse->get('accounts'); + $accounts = $plaid->getAccounts($this->accessToken->api_key)['accounts']; foreach ($accounts as $account) { /** @var Account $localAccount */ @@ -59,78 +51,96 @@ public function handle(PlaidServiceContract $plaid): void } } - $transactions = $transactionsResponse->get('transactions'); - - foreach ($transactions as $transaction) { - $localTransactions = Transaction::where(function ($query) use ($transaction): void { - $query->where('transaction_id', $transaction->transaction_id); - - /** - * Due to how Plaid handles pending transactions, we need to delete the transaction with a pending transaction id, - * and then create a new transaction - * - * @see https://plaid.com/docs/transactions/transactions-data/#reconciling-transactions - */ - if ($transaction->pending_transaction_id) { - $query->orWhere('transaction_id', $transaction->pending_transaction_id); - } - })->get(); - - $localTransactions->map(function ($localTransaction) use ($transaction): void { - if ($transaction->pending_transaction_id === $localTransaction->transaction_id) { - $localTransaction->delete(); - $localTransaction = null; - } - - if (empty($localTransaction)) { - $localTransaction = $this->createLocalTransaction($transaction); - } else { - $localTransaction->update([ - 'account_id' => $transaction->account_id, - 'amount' => $transaction->amount, - 'category_id' => $transaction->category_id, - 'date' => Carbon::parse($transaction->date), - 'name' => $transaction->name, - 'pending' => $transaction->pending, - 'transaction_id' => $transaction->transaction_id, - 'transaction_type' => $transaction->transaction_type, - 'pending_transaction_id' => $transaction->pending_transaction_id, - ]); - } - - $this->syncTransactions($transaction, $localTransaction); - }); - - if ($localTransactions->isEmpty()) { - $this->createLocalTransaction($transaction); + do { + $transactionsResponse = $plaid->syncTransactions($this->accessToken->api_key, $this->accessToken->settings['cursor'] ?? null); + + foreach ($transactionsResponse['added'] as $transaction) { + $this->updateLocalTransaction($transaction); } - } + + foreach ($transactionsResponse['modified'] as $transaction) { + $this->updateLocalTransaction($transaction); + } + + foreach ($transactionsResponse['removed'] as $transaction) { + Transaction::query()->firstWhere('transaction_id', $transaction->transaction_id)?->delete(); + } + $this->accessToken->settings = array_merge($this->accessToken->settings, [ + 'cursor' => $transactionsResponse['next_cursor'], + ]); + $this->accessToken->save(); + + } while ($transactionsResponse['has_more'] ?? false); } - protected function syncTransactions($transaction, Transaction $localTransaction): void + protected function syncTags($transaction, Transaction $localTransaction): void { - $categoriesToSync = []; $categories = $transaction->category ?? []; $localTransaction->attachTags($categories, 'finance'); + + $counterParties = $transaction->countyparties ?? []; + + foreach ($counterParties as $party) { + $localTransaction->attachTag($party->name, $party->type); + } } protected function createLocalTransaction($transaction) { - $localTransaction = Transaction::firstOrCreate([ - 'transaction_id' => $transaction->transaction_id, - ], [ + $localTransaction = Transaction::create([ 'account_id' => $transaction->account_id, 'amount' => $transaction->amount, 'category_id' => $transaction->category_id, - 'date' => Carbon::parse($transaction->date), + 'date' => Carbon::parse($transaction->authorized_date ?? $transaction->date), 'name' => $transaction->name, 'pending' => $transaction->pending, 'transaction_id' => $transaction->transaction_id, - 'transaction_type' => $transaction->transaction_type, - 'pending_transaction_id' => $transaction->pending_transaction_id, + 'transaction_type' => $transaction->payment_channel, + + 'personal_finance_category' => $transaction->personal_finance_category?->primary, + 'personal_finance_category_detailed' => $transaction->personal_finance_category?->detailed, + 'personal_finance_icon' => $transaction->personal_finance_category_icon_url, + + 'seller_icon' => $transaction->logo_url, + + 'data' => $transaction, ]); + $this->syncTags($transaction, $localTransaction); return $localTransaction; } + + protected function updateLocalTransaction($transaction) + { + $localTransaction = Transaction::query()->firstWhere('transaction_id', $transaction->transaction_id); + + if (empty($localTransaction)) { + $localTransaction = Transaction::query()->firstWhere('transaction_id', $transaction->pending_transaction_id); + } + + if (empty($localTransaction)) { + $localTransaction = $this->createLocalTransaction($transaction); + } + + $localTransaction->update([ + 'account_id' => $transaction->account_id, + 'amount' => $transaction->amount, + 'category_id' => $transaction->category_id, + 'date' => Carbon::parse($transaction->authorized_date ?? $transaction->date), + 'name' => $transaction->name, + 'pending' => $transaction->pending, + 'transaction_id' => $transaction->transaction_id, + 'transaction_type' => $transaction->payment_channel, + + 'personal_finance_category' => $transaction->personal_finance_category?->primary, + 'personal_finance_category_detailed' => $transaction->personal_finance_category?->detailed, + 'personal_finance_icon' => $transaction->personal_finance_category_icon_url, + + 'seller_icon' => $transaction->logo_url, + + 'data' => $transaction, + ]); + $this->syncTags($transaction, $localTransaction); + } } diff --git a/app/Jobs/Servers/AbstractSyncServerResourceJob.php b/app/Jobs/Servers/AbstractSyncServerResourceJob.php index b273e31..2d95902 100644 --- a/app/Jobs/Servers/AbstractSyncServerResourceJob.php +++ b/app/Jobs/Servers/AbstractSyncServerResourceJob.php @@ -14,6 +14,9 @@ abstract class AbstractSyncServerResourceJob extends AbstractSyncResourceJob public function handle(ServerServiceFactory $serviceFactory) { + if ($this->batch()?->cancelled()) { + return; + } $this->service = $serviceFactory->make($this->credential); $this->sync(); } diff --git a/app/Jobs/Servers/LaravelForgeServersSyncJob.php b/app/Jobs/Servers/LaravelForgeServersSyncJob.php index 5c5d981..45e405b 100644 --- a/app/Jobs/Servers/LaravelForgeServersSyncJob.php +++ b/app/Jobs/Servers/LaravelForgeServersSyncJob.php @@ -11,6 +11,9 @@ class LaravelForgeServersSyncJob extends AbstractSyncServerResourceJob { public function handle(ServerServiceFactory $serviceFactory) { + if ($this->batch()?->cancelled()) { + return; + } $this->sync(); } diff --git a/app/Jobs/SyncMailboxIfCredentialsAreSet.php b/app/Jobs/SyncMailboxIfCredentialsAreSet.php new file mode 100644 index 0000000..18af23f --- /dev/null +++ b/app/Jobs/SyncMailboxIfCredentialsAreSet.php @@ -0,0 +1,170 @@ +since = now()->subMonth(); + } + + public function handle(ImapFactoryService $imapFactory): void + { + if ($this->batch()?->cancelled()) { + return; + } + $imapService = $imapFactory->make($this->credential); + + info('Imap service seems to have credentials, trying to access inbox'); + $start = now(); + $messages = $imapService->findAllFromDate('INBOX', $this->since); + $end = now(); + + info('Found '.count($messages).' messages in '.$start->diffInSeconds($end).' seconds'); + + foreach ($messages as $i => $message) { + $trackedMessage = $this->credential->messages()->firstWhere([ + 'type' => 'email', + 'event_id' => $message['id'], + ]); + + if (empty($trackedMessage)) { + $body = $imapService->findMessage((string) $message['id']); + $trackedMessage = $this->credential->messages()->create([ + 'from_person' => $this->getPersonFromEmail($message), + 'from_email' => (empty($message['from']['email']) ? null : $message['from']['email']) ?? $message['addressed-from']['email'] ?? null, + 'to_email' => (empty($message['to']['email']) ? null : $message['to']['email']) ?? $message['addressed-to']['email'] ?? null, + 'to_person' => $this->getPersonToEmail($message), + 'type' => 'email', + 'event_id' => $message['id'], + 'originated_at' => $message['date'], + 'subject' => $body['subject'], + 'is_decrypted' => true, + 'message' => $body['body'], + 'html_message' => $body['view'], + 'seen' => $body['seen'], + 'spam' => $body['spam'], + 'answered' => $body['answered'], + ]); + } else { + $body = $imapService->findMessage((string) $message['id']); + collect([ + 'originated_at' => $message['date'], + 'is_decrypted' => true, + 'message' => $body['body'], + 'html_message' => $body['view'], + 'from_email' => (empty($message['from']['email']) ? null : $message['from']['email']) ?? $message['addressed-from']['email'] ?? null, + 'to_email' => (empty($message['to']['email']) ? null : $message['to']['email']) ?? $message['addressed-to']['email'] ?? null, + 'subject' => $body['subject'], + 'seen' => $body['seen'], + 'spam' => $body['spam'], + 'answered' => $body['answered'], + ])->map(function ($value, $key) use ($trackedMessage) { + if ($value !== $trackedMessage->$key) { + $trackedMessage->$key = $value; + } + }); + + if ($trackedMessage->isDirty([ + 'originated_at', + 'is_decrypted', + 'message', + 'html_message', + 'seen', + 'spam', + 'answered', + ])) { + $this->getPersonToEmail($message); + $this->getPersonFromEmail($message); + $trackedMessage->save(); + } + } + + info('Processed '.$i.'/'.count($messages)); + } + + } + + protected function getPersonFromEmail(array $message) + { + $fromName = $message['from']['name'] ?? $message['addressed-from']['name'] ?? $message['from']['email']; + $fromEmail = $message['from']['email'] ?? $message['addressed-from']['email'] ?? null; + + if (empty($fromEmail)) { + dd('no email found, where should we get an email', $message); + + return; + } + + $person = Person::whereJsonContains('emails', $fromEmail) + // for now, this is fine, my email base does support this idea, but I know if someone/ + // wanted to be malicious they could take advantage of this. + ->orWhere('name', $fromName) + ->first(); + + if (empty($person)) { + $person = Person::create([ + 'name' => $fromName, + 'emails' => [$fromEmail], + ]); + } + $emails = array_values(array_unique(array_filter(array_merge($person->emails, [$message['from']['email']]), fn ($val) => ! empty($val)))); + + if (! empty(array_diff($person->emails, $emails)) || ! empty(array_diff($emails, $person->emails))) { + $person->update(compact('emails')); + } + + return $person->id; + } + + protected function getPersonToEmail(array $message) + { + $fromName = $message['to']['name'] ?? $message['addressed-to']['name'] ?? $message['to']['email']; + $fromEmail = (empty($message['to']['email']) ? null : $message['addressed-to']['email']) ?? $message['to']['original'] ?? null; + + if (empty($fromEmail)) { + dd('no email found, where should we get an email', $message); + + return; + } + + if (str_starts_with($fromEmail, '<')) { + $fromEmail = trim($fromEmail, '<>'); + } + + $person = Person::whereJsonContains('emails', $fromEmail) + // for now, this is fine, my email base does support this idea, but I know if someone/ + // wanted to be malicious they could take advantage of this. + ->first(); + + if (empty($person)) { + $person = Person::first(); + info('This thing from email: '.$fromEmail); + + $person->update([ + 'emails' => array_values(array_unique(array_merge($person->emails, [strtolower($fromEmail)]))), + ]); + // Need some way to determine a "default" user to assign messages to if they don't already have a person record. + } + + return $person->id; + } +} diff --git a/app/Listeners/CreateDefaultProjectsListener.php b/app/Listeners/CreateDefaultProjectsListener.php new file mode 100644 index 0000000..1c64edf --- /dev/null +++ b/app/Listeners/CreateDefaultProjectsListener.php @@ -0,0 +1,36 @@ +model; + + foreach (static::DEFAULT_PROJECT_NAMES as $name) { + $user->projects()->create([ + 'name' => $name, + 'settings' => [], + 'team_id' => $user->teams()->first()->id ?? 1, + ]); + } + } +} diff --git a/app/Listeners/CreatePersonForNewUserListener.php b/app/Listeners/CreatePersonForNewUserListener.php new file mode 100644 index 0000000..32d42e0 --- /dev/null +++ b/app/Listeners/CreatePersonForNewUserListener.php @@ -0,0 +1,21 @@ +model; + Person::create([ + 'name' => $user->name, + 'emails' => [$user->email], + 'names' => [$user->name], + ]); + } +} diff --git a/app/Listeners/Finance/ApplyUserAutomatedTagsToTransaction.php b/app/Listeners/Finance/ApplyUserAutomatedTagsToTransaction.php index 991a9a5..0ee77eb 100644 --- a/app/Listeners/Finance/ApplyUserAutomatedTagsToTransaction.php +++ b/app/Listeners/Finance/ApplyUserAutomatedTagsToTransaction.php @@ -1,4 +1,5 @@ model->load('account.credential.user'); $event->model->refresh(); + + /** @var Transaction $transaction */ $transaction = $event->model; /** @var Account $account */ $account = $transaction->account; @@ -40,36 +36,21 @@ public function handle(TransactionCreated $event): void $user = $credential->user; - if (empty($user)) { - $this->logger->warning('No user found for account', [ - 'account' => $account->id, - 'transaction' => $transaction->id, - 'credential' => $account->credential?->user_id - ]); return; } $tags = $user->tags()->with('conditions')->where('type', 'automatic')->get(); - foreach ($tags as $tag) { - $conditions = $tag->conditions; - $conditionsMet = false; - foreach ($conditions as $condition) { - /** @var AbstractLogicalOperator $operator */ - $operator = ConditionService::AVAILABLE_CONDITIONS[$condition->comparator]; + $conditionService = new ConditionService(); - /** @var AbstractLogicalOperator $operatorInstance */ - $operatorInstance = new $operator; - - $value = Arr::get(compact('transaction'), $condition->parameter); - - if ($operatorInstance->compute($condition->value, $value)) { - $conditionsMet = true; - } - } + $tagsToApply = $tags->filter(fn (Tag $tag) => $conditionService->process($tag, [ + 'transaction' => $transaction, + 'account' => $account, + ])); - if ($conditionsMet) { + foreach ($tagsToApply as $tag) { + if (! $transaction->tags()->where('id', $tag->id)->exists()) { $transaction->tags()->attach($tag); } } diff --git a/app/Listeners/Finance/CreateDefaultAutomatedTags.php b/app/Listeners/Finance/CreateDefaultAutomatedTags.php index 324a57a..c3c061b 100644 --- a/app/Listeners/Finance/CreateDefaultAutomatedTags.php +++ b/app/Listeners/Finance/CreateDefaultAutomatedTags.php @@ -1,13 +1,13 @@ 'subscriptions', 'type' => 'automatic', + 'must_all_conditions_pass' => false, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'hulu', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'disney', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'HBO', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'twitch', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'github', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'plex', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'protonmail', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'youtube', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'Subscription', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'Discord', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'netflix', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'WASABI TECHNOLOGIES', ], @@ -82,6 +83,7 @@ class CreateDefaultAutomatedTags [ 'name' => 'games', 'type' => 'automatic', + 'must_all_conditions_pass' => false, 'conditions' => [ [ 'parameter' => 'transaction.name', @@ -109,7 +111,7 @@ class CreateDefaultAutomatedTags 'value' => 'gamestop', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'Video Games', ], @@ -123,6 +125,7 @@ class CreateDefaultAutomatedTags [ 'name' => 'bills', 'type' => 'automatic', + 'must_all_conditions_pass' => false, 'conditions' => [ [ 'parameter' => 'tag.name', @@ -131,22 +134,22 @@ class CreateDefaultAutomatedTags 'value' => 'utilities', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'Loans and Mortgages', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Billpay', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'USAA P&C INT AUTOPAY', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Car Dealers and Leasing', ], @@ -155,29 +158,30 @@ class CreateDefaultAutomatedTags [ 'name' => 'utilities', 'type' => 'automatic', + 'must_all_conditions_pass' => false, 'conditions' => [ [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Cable', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Telecommunication Services', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Utilities', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Sanitary and Waste Management', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, // This is for people who get their power/water from the city (like those in petoskey) 'value' => 'Government Departments and Agencies', @@ -187,14 +191,15 @@ class CreateDefaultAutomatedTags [ 'name' => 'fast food/restaurants', 'type' => 'automatic', + 'must_all_conditions_pass' => false, 'conditions' => [ [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'Restaurants', ], [ - 'parameter' => 'category.name', + 'parameter' => 'transaction.category.name', 'comparator' => Condition::COMPARATOR_EQUALS, 'value' => 'Fast Food', ], @@ -203,9 +208,10 @@ class CreateDefaultAutomatedTags [ 'name' => 'fees', 'type' => 'automatic', + 'must_all_conditions_pass' => true, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'fee', ], @@ -214,9 +220,10 @@ class CreateDefaultAutomatedTags [ 'name' => 'via Privacy.com', 'type' => 'automatic', + 'must_all_conditions_pass' => true, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_STARTS_WITH, 'value' => 'PWP*', ], @@ -225,9 +232,10 @@ class CreateDefaultAutomatedTags [ 'name' => 'transfer', 'type' => 'automatic', + 'must_all_conditions_pass' => true, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_LIKE, 'value' => 'transfer', ], @@ -236,20 +244,21 @@ class CreateDefaultAutomatedTags [ 'name' => 'credit/income', 'type' => 'automatic', + 'must_all_conditions_pass' => true, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_NOT_LIKE, 'value' => 'transfer', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_NOT_LIKE, 'value' => 'fee', ], [ - 'parameter' => 'amount', - 'comparator' => Condition::COMPARATOR_LESS_THAN, + 'parameter' => 'transaction.amount', + 'comparator' => Condition::COMPARATOR_GREATER_THAN, 'value' => 0, ], ], @@ -257,20 +266,21 @@ class CreateDefaultAutomatedTags [ 'name' => 'debit/expense', 'type' => 'automatic', + 'must_all_conditions_pass' => true, 'conditions' => [ [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_NOT_LIKE, 'value' => 'transfer', ], [ - 'parameter' => 'name', + 'parameter' => 'transaction.name', 'comparator' => Condition::COMPARATOR_NOT_LIKE, 'value' => 'fee', ], [ - 'parameter' => 'amount', - 'comparator' => Condition::COMPARATOR_GREATER_THAN, + 'parameter' => 'transaction.amount', + 'comparator' => Condition::COMPARATOR_LESS_THAN, 'value' => 0, ], ], diff --git a/app/Models/Condition.php b/app/Models/Condition.php index ff449b4..8d54ed0 100644 --- a/app/Models/Condition.php +++ b/app/Models/Condition.php @@ -16,6 +16,7 @@ class Condition extends Model implements Crud { use HasFactory; + public const ALL_COMPARATORS = [ self::COMPARATOR_EQUALS, self::COMPARATOR_LIKE_STRICT, @@ -33,17 +34,29 @@ class Condition extends Model implements Crud ]; public const COMPARATOR_EQUALS = 'EQUALS'; + public const COMPARATOR_NOT_EQUAL = 'NOT_EQUAL'; + public const COMPARATOR_LIKE = 'LIKE'; + public const COMPARATOR_LIKE_STRICT = 'LIKE_STRICT'; + public const COMPARATOR_NOT_LIKE = 'NOTLIKE'; + public const COMPARATOR_IN = 'IN'; + public const COMPARATOR_NOT_IN = 'NOTIN'; + public const COMPARATOR_STARTS_WITH = 'STARTS_WITH'; + public const COMPARATOR_ENDS_WITH = 'ENDS_WITH'; + public const COMPARATOR_LESS_THAN = 'LESS_THAN'; + public const COMPARATOR_LESS_THAN_EQUAL = 'LESS_THAN_EQUAL'; + public const COMPARATOR_GREATER_THAN = 'GREATER_THAN'; + public const COMPARATOR_GREATER_THAN_EQUAL = 'GREATER_THAN_EQUAL'; public $fillable = [ diff --git a/app/Models/Credential.php b/app/Models/Credential.php index 6705753..7d40b70 100644 --- a/app/Models/Credential.php +++ b/app/Models/Credential.php @@ -61,6 +61,8 @@ class Credential extends Model implements Crud, ModelQuery public const TYPE_SSH = 'ssh'; + public const TYPE_EMAIL = 'email'; + public const ALL_DOMAIN_PROVIDERS = [ self::DIGITAL_OCEAN, self::CLOUDFLARE, @@ -184,4 +186,9 @@ public function accounts() { return $this->hasMany(Account::class); } + + public function messages() + { + return $this->hasMany(Message::class); + } } diff --git a/app/Models/Finance/Transaction.php b/app/Models/Finance/Transaction.php index 05ccc15..9e2f884 100644 --- a/app/Models/Finance/Transaction.php +++ b/app/Models/Finance/Transaction.php @@ -26,11 +26,17 @@ class Transaction extends Model implements Crud, ModelQuery 'amount', 'account_id', 'date', + 'pending', 'category_id', 'transaction_id', 'transaction_type', - 'pending_transaction_id', + + 'personal_finance_category', + 'personal_finance_category_detailed', + 'personal_finance_icon', + 'seller_icon', + 'data', ]; diff --git a/app/Models/Message.php b/app/Models/Message.php index 8725c94..5ef5ac2 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -12,10 +12,44 @@ use App\Events\Models\Message\MessageUpdating; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Spatie\Tags\HasTags; +use Staudenmeir\EloquentJsonRelations\HasJsonRelationships; +/** + * @property-read Credential $credential + */ +/** @mixin \Eloquent */ class Message extends Model { - use HasFactory; + use HasFactory, HasJsonRelationships, HasTags; + + public $fillable = [ + 'from_person', + 'to_person', + 'to_email', + 'from_email', + 'thread_id', + 'type', + 'event_id', + 'originated_at', + 'thumbnail_url', + 'is_decrypted', + 'message', + 'html_message', + 'settings', + 'seen', + 'spam', + 'answered', + 'subject', + ]; + + public $casts = [ + 'seen' => 'bool', + 'spam' => 'bool', + 'answered' => 'bool', + 'originated_at' => 'timestamp', + 'settings' => 'json', + ]; public $appends = ['is_user']; @@ -32,4 +66,24 @@ public function getIsUserAttribute() { return auth()->id() === $this->from_person; } + + public function credential() + { + return $this->belongsTo(Credential::class); + } + + public function fromPerson() + { + return $this->belongsTo(Person::class, 'from_person'); + } + + public function from() + { + return $this->hasManyJson(Person::class, 'emails', 'from_email'); + } + + public function to() + { + return $this->hasManyJson(Person::class, 'emails', 'to_email'); + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 4cbff84..f3d737f 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -18,12 +18,21 @@ use App\Models\Traits\HasConditions; use Illuminate\Database\Eloquent\Factories\HasFactory; +/** + * @property bool $must_all_conditions_pass + */ class Tag extends \Spatie\Tags\Tag implements Conditionable, Crud, ModelQuery { // Tags with conditions will essentially only be applied if the conditions pass. use HasConditions, HasFactory; - public $guarded = []; + public $fillable = [ + 'name', + 'slug', + 'must_all_conditions_pass', + 'type', + 'order_column', + ]; public $dispatchesEvents = [ 'created' => TagCreated::class, @@ -78,4 +87,9 @@ public function people() { return $this->morphedByMany(Person::class, 'taggable'); } + + public function messages() + { + return $this->morphedByMany(Message::class, 'taggable'); + } } diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 0000000..576b13d --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,13 @@ +hasManyThrough(Account::class, Credential::class); } + + public function messages() + { + return $this->hasManyThrough(Message::class, Credential::class)->orderByDesc('originated_at'); + } } diff --git a/app/Policies/TaskPolicy.php b/app/Policies/TaskPolicy.php new file mode 100644 index 0000000..c1ff959 --- /dev/null +++ b/app/Policies/TaskPolicy.php @@ -0,0 +1,67 @@ +app->bind(PlaidServiceContract::class, PlaidService::class); + $this->app->bind(CredentialRepositoryContract::class, CredentialRepository::class); + $this->app->bind(ImapServiceContract::class, ImapCredentialService::class); + $this->app->bind(WeatherServiceContract::class, OpenWeatherService::class); $this->app->alias(Operator::class, 'operator'); } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 78dc9d4..722069e 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -8,6 +8,7 @@ use App\Events\Domains\DnsRecordVerified; use App\Events\Domains\DomainCreated; use App\Events\Domains\NameServerRecordVerified; +use App\Events\Models\Message\MessageCreated; use App\Events\Pages\PageCreated; use App\Events\Pages\PageUpdated; use App\Listeners; @@ -25,6 +26,9 @@ class EventServiceProvider extends ServiceProvider * @var array> */ protected $listen = [ + MessageCreated::class => [ + DebugEventListener::class, // code: this is an autogenerated line + ], NameServerRecordVerified::class => [ DebugEventListener::class, // code: this is an autogenerated line ], @@ -47,6 +51,10 @@ class EventServiceProvider extends ServiceProvider ], Events\Models\User\UserCreated::class => [ Listeners\Finance\CreateDefaultAutomatedTags::class, + Listeners\CreateDefaultProjectsListener::class, + ], + Events\Models\Transaction\TransactionCreated::class => [ + Listeners\Finance\ApplyUserAutomatedTagsToTransaction::class, ], ]; diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 99de7aa..a825a7c 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -46,7 +46,6 @@ public function boot(): void protected function configurePermissions(): void { Jetstream::defaultApiTokenPermissions(['read']); - Jetstream::role('admin', 'Administrator', [ 'create', 'read', diff --git a/app/Repositories/CredentialRepository.php b/app/Repositories/CredentialRepository.php new file mode 100644 index 0000000..777d7a0 --- /dev/null +++ b/app/Repositories/CredentialRepository.php @@ -0,0 +1,19 @@ +where('type', $type) + ->paginate($limit, ['*'], 'page', $page); + } +} diff --git a/app/Services/Condition/ArrayContainsValueOperator.php b/app/Services/Condition/ArrayContainsValueOperator.php new file mode 100644 index 0000000..d756f37 --- /dev/null +++ b/app/Services/Condition/ArrayContainsValueOperator.php @@ -0,0 +1,20 @@ +compute(haystack: $haystack, needle: $needle); + } +} diff --git a/app/Services/Condition/ContainsValueOperator.php b/app/Services/Condition/ContainsValueOperator.php index 20a1d89..4911081 100644 --- a/app/Services/Condition/ContainsValueOperator.php +++ b/app/Services/Condition/ContainsValueOperator.php @@ -16,6 +16,11 @@ public function compute(mixed $needle, mixed $haystack): bool return isset($haystack->$needle); } + if (is_null($needle) && ! is_null($haystack)) { + // if one is null, and the other, then we obvs need to return false; + return false; + } + return str_contains(strtolower((string) $haystack), strtolower($needle)); } } diff --git a/app/Services/Condition/ContainsValueStrictOperator.php b/app/Services/Condition/ContainsValueStrictOperator.php index 559485b..53f8471 100644 --- a/app/Services/Condition/ContainsValueStrictOperator.php +++ b/app/Services/Condition/ContainsValueStrictOperator.php @@ -8,6 +8,10 @@ class ContainsValueStrictOperator extends AbstractLogicalOperator { public function compute(mixed $needle, mixed $haystack): bool { + if (gettype($needle) !== gettype($haystack)) { + return false; + } + if (is_array($haystack)) { return in_array($needle, $haystack, true); } diff --git a/app/Services/Condition/GreaterThanOperator.php b/app/Services/Condition/GreaterThanOperator.php index d8cebb1..122788e 100644 --- a/app/Services/Condition/GreaterThanOperator.php +++ b/app/Services/Condition/GreaterThanOperator.php @@ -8,31 +8,32 @@ class GreaterThanOperator extends AbstractLogicalOperator { - public function compute(mixed $firstValue, mixed $secondValue): bool + public function compute(mixed $valueFromCondition, mixed $valueFromParameter): bool { - if (is_array($firstValue) || is_array($secondValue)) { + if (is_array($valueFromCondition) || is_array($valueFromParameter)) { return false; } - if (strtotime($firstValue) && strtotime($secondValue)) { - // we're dealing with a date, or date time. - return Carbon::parse($firstValue)->isAfter(Carbon::parse($secondValue)); - } + if (is_string($valueFromCondition) && is_string($valueFromParameter)) { + + if (strtotime($valueFromCondition) && strtotime($valueFromParameter)) { + // we're dealing with a date, or date time. + return Carbon::parse($valueFromCondition)->isAfter(Carbon::parse($valueFromParameter)); + } - if (is_string($firstValue) && is_string($secondValue)) { // This is meant to be a numeric or date operator, checking the greatness of a string is beyond the scope of this lib. - return strlen($firstValue) > strlen($secondValue); + return strlen($valueFromCondition) > strlen($valueFromParameter); } - if (! is_numeric($firstValue)) { + if (! is_numeric($valueFromCondition)) { // At the time of writing, I'm not sure what could end up here other than maybe objects/arrays? - $firstValue = strlen($firstValue); + $valueFromCondition = strlen($valueFromCondition); } - if (! is_numeric($secondValue)) { - $secondValue = strlen($secondValue); + if (! is_numeric($valueFromParameter)) { + $valueFromParameter = strlen($valueFromParameter); } - return $firstValue > $secondValue; + return $valueFromCondition > $valueFromParameter; } } diff --git a/app/Services/Condition/GreaterThanOrEqualToOperator.php b/app/Services/Condition/GreaterThanOrEqualToOperator.php index 0487e61..365aaf0 100644 --- a/app/Services/Condition/GreaterThanOrEqualToOperator.php +++ b/app/Services/Condition/GreaterThanOrEqualToOperator.php @@ -6,8 +6,8 @@ class GreaterThanOrEqualToOperator extends AbstractLogicalOperator { - public function compute(mixed $firstValue, mixed $secondValue): bool + public function compute(mixed $valueFromCondition, mixed $valueFromParameter): bool { - return (new GreaterThanOperator)->compute($firstValue, $secondValue) || $firstValue === $secondValue; + return (new GreaterThanOperator)->compute($valueFromCondition, $valueFromParameter) || $valueFromCondition === $valueFromParameter; } } diff --git a/app/Services/Condition/HasRoleOperator.php b/app/Services/Condition/HasRoleOperator.php index 9616ac5..c037613 100644 --- a/app/Services/Condition/HasRoleOperator.php +++ b/app/Services/Condition/HasRoleOperator.php @@ -4,11 +4,9 @@ namespace App\Services\Condition; -use App\Models\User; - class HasRoleOperator extends AbstractLogicalOperator { - public function compute(User $user, string $role): bool + public function compute(mixed $user, mixed $role): bool { return $user->hasRole($role); } diff --git a/app/Services/Condition/LessThanOperator.php b/app/Services/Condition/LessThanOperator.php index a58cd2f..655f18a 100644 --- a/app/Services/Condition/LessThanOperator.php +++ b/app/Services/Condition/LessThanOperator.php @@ -8,31 +8,38 @@ class LessThanOperator extends AbstractLogicalOperator { - public function compute(mixed $firstValue, mixed $secondValue): bool + public function compute(mixed $valueFromCondition, mixed $valueFromParameter): bool { - if (is_array($firstValue) || is_array($secondValue)) { + if (is_array($valueFromCondition) || is_array($valueFromParameter)) { return false; } - if (strtotime($firstValue) && strtotime($secondValue)) { - // we're dealing with a date, or date time. - return Carbon::parse($firstValue)->isBefore(Carbon::parse($secondValue)); + if (is_string($valueFromCondition) && is_string($valueFromParameter)) { + if (strtotime($valueFromCondition) && strtotime($valueFromParameter)) { + // we're dealing with a date, or date time. + return Carbon::parse($valueFromCondition)->isBefore(Carbon::parse($valueFromParameter)); + } + + return strlen($valueFromCondition) < strlen($valueFromParameter); } - if (is_string($firstValue) && is_string($secondValue)) { - // This is meant to be a numeric or date operator, checking the greatness of a string is beyond the scope of this lib. - return strlen($firstValue) < strlen($secondValue); + if (is_null($valueFromCondition)) { + dd($valueFromCondition, $valueFromParameter); } - if (! is_numeric($firstValue)) { + if (! is_numeric($valueFromCondition)) { // At the time of writing, I'm not sure what could end up here other than maybe objects/arrays? - $firstValue = strlen($firstValue); + $valueFromCondition = strlen($valueFromCondition); + } + + if (is_null($valueFromParameter)) { + dd($valueFromCondition, $valueFromParameter); } - if (! is_numeric($secondValue)) { - $secondValue = strlen($secondValue); + if (! is_numeric($valueFromParameter)) { + $valueFromParameter = strlen($valueFromParameter); } - return $firstValue < $secondValue; + return $valueFromCondition < $valueFromParameter; } } diff --git a/app/Services/Condition/LessThanOrEqualToOperator.php b/app/Services/Condition/LessThanOrEqualToOperator.php index ef2b575..f83cc07 100644 --- a/app/Services/Condition/LessThanOrEqualToOperator.php +++ b/app/Services/Condition/LessThanOrEqualToOperator.php @@ -6,8 +6,8 @@ class LessThanOrEqualToOperator extends AbstractLogicalOperator { - public function compute(mixed $firstValue, mixed $secondValue): bool + public function compute(mixed $valueFromCondition, mixed $valueFromParameter): bool { - return (new LessThanOperator)->compute($firstValue, $secondValue) || $firstValue === $secondValue; + return (new LessThanOperator)->compute($valueFromCondition, $valueFromParameter) || $valueFromCondition === $valueFromParameter; } } diff --git a/app/Services/ConditionService.php b/app/Services/ConditionService.php index d7d4a21..89b62b5 100644 --- a/app/Services/ConditionService.php +++ b/app/Services/ConditionService.php @@ -7,6 +7,9 @@ use App\Contracts\Conditionable; use App\Models\Condition; use App\Models\Navigation; +use App\Models\Tag; +use App\Services\Condition\AbstractLogicalOperator; +use App\Services\Condition\ArrayContainsValueOperator; use App\Services\Condition\ContainsValueOperator; use App\Services\Condition\ContainsValueStrictOperator; use App\Services\Condition\DoesntContainValueOperator; @@ -19,6 +22,7 @@ use App\Services\Condition\LessThanOperator; use App\Services\Condition\LessThanOrEqualToOperator; use App\Services\Condition\StartsWithOperator; +use Illuminate\Support\Arr; class ConditionService { @@ -28,7 +32,7 @@ class ConditionService Condition::COMPARATOR_EQUALS => EqualsValueOperator::class, // *strings* or arrays, - Condition::COMPARATOR_IN => ContainsValueOperator::class, + Condition::COMPARATOR_IN => ArrayContainsValueOperator::class, Condition::COMPARATOR_LIKE => ContainsValueOperator::class, Condition::COMPARATOR_LIKE_STRICT => ContainsValueStrictOperator::class, Condition::COMPARATOR_NOT_LIKE => DoesntContainValueOperator::class, @@ -50,7 +54,7 @@ public function navigation() { // So we want to filter out any nav items $navItems = Navigation::query() - ->with('conditions') + ->with('conditions', 'children') ->where('authentication_required', auth()->check()) ->whereNull('parent_id') ->orderBy('order') @@ -61,28 +65,44 @@ public function navigation() return $item; }); - return $navItems->filter(function (Navigation $item) { - return $this->process($item); - }); + return $navItems->filter(fn (Navigation $item) => $this->process($item)); } - public function process(Conditionable $item) + public function process(Conditionable $item, array $additionalValueData = []) { if ($item->conditions->count() === 0) { return true; } - return $item->conditions->filter(function (Condition $condition) { + $returnedValue = true; + /** @var Tag $condition */ + foreach ($item->conditions as $condition) { $comparator = static::AVAILABLE_CONDITIONS[$condition->comparator]; - /** @var ContainsValueOperator $instance */ + /** @var AbstractLogicalOperator $instance */ $instance = new $comparator; - return $instance->compute($this->processParameter($condition->parameter), $condition->value); - })->count() === $item->conditions->count(); + $passesCondition = $instance->compute( + // Looking for the condition value + $condition->value, + // inside the parameter's interpolated value. + $this->processParameter($condition->parameter, $additionalValueData), + ); + + if ($passesCondition && ! $item->must_all_conditions_pass) { + return true; + } + + if (! $passesCondition) { + $returnedValue = false; + } + } + + return $returnedValue; } - protected function processParameter(string $parameter) + protected function processParameter(string $parameter, array $additionalData) { + // This might be some thing like config:app.env to return a config value if (str_contains($parameter, ':')) { [$primaryKey, $field] = explode(':', $parameter); @@ -95,7 +115,9 @@ protected function processParameter(string $parameter) return auth()->user(); } - dd('this is likely a parameter, not a function', $parameter); + // This can be an single dimensions, or a multidimensional array + // Access via dot notation. + return Arr::get($additionalData, $parameter); } protected function matchCustomPrimaryKeyFunctions(string $key, ?string $parameter) diff --git a/app/Services/Development/DescribeTableService.php b/app/Services/Development/DescribeTableService.php index c0f568a..01f8fbc 100644 --- a/app/Services/Development/DescribeTableService.php +++ b/app/Services/Development/DescribeTableService.php @@ -65,7 +65,20 @@ public function describe(Model $model): array ]); }, []); - $methodsThatReturnAClass = array_filter($returnTypes, fn (\ReflectionNamedType $type) => class_exists($type->getName())); + $methodsThatReturnAClass = array_filter($returnTypes, function (\ReflectionNamedType|\ReflectionUnionType $type) { + if ($type instanceof \ReflectionUnionType) { + $allTypes = $type->getTypes(); + + /** @var \ReflectionNamedType $t */ + foreach ($allTypes as $t) { + if (! class_exists($t->getName())) { + return false; + } + } + } + + return class_exists($type->getName()); + }); $relations = array_filter($methodsThatReturnAClass, function ($type) { $c = new \ReflectionClass($type->getName()); @@ -82,6 +95,7 @@ public function describe(Model $model): array return false; }); + $fillable = empty($model->getFillable()) ? ['name'] : $model->getFillable(); // $actions = Code::instancesOf(ActionInterface::class)->getClasses(); // @@ -90,11 +104,40 @@ public function describe(Model $model): array return [ 'actions' => array_map(fn ($class) => (array) (new $class), $model->actions ?? []), 'query_actions' => ActionFilter::WHITELISTED_ACTIONS, - 'fillable' => empty($model->getFillable()) ? ['name'] : $model->getFillable(), + 'fillable' => $fillable, 'fields' => $fields, 'filters' => array_map(fn ($query) => $query->Column_name, $indexes), 'includes' => array_keys($relations), 'sorts' => $mapField($sorts), + 'types' => array_reduce($description, function ($allFields, $field) { + $simpleType = explode('(', $field->Type, 2); + + if (count($simpleType) > 1) { + $possibleLimit = explode(')', $simpleType[1], 2); + } + + return array_merge( + $allFields, + [ + $field->Field => array_merge([ + 'type' => match ($simpleType[0]) { + 'bigint' => 'number', + 'varchar' => 'text', + 'longtext' => 'textarea', + 'datetime', 'timestamp' => 'datetime', + + default => $simpleType[0] + }, + ], $field->Default ? [ + 'value' => $field->Default, + ] : [], + isset($possibleLimit) ? [ + 'max-length' => $possibleLimit[0], + ] : [], + ), + ] + ); + }, []), 'required' => $mapField(array_filter($description, fn ($query) => $query->Null === 'NO' && $query->Extra !== 'auto_increment')), ]; } diff --git a/app/Services/Domain/CloudflareDomainService.php b/app/Services/Domain/CloudflareDomainService.php index 67151e3..8dbdf29 100644 --- a/app/Services/Domain/CloudflareDomainService.php +++ b/app/Services/Domain/CloudflareDomainService.php @@ -37,7 +37,7 @@ public function __construct( public function getDomains(int $limit = 10, int $page = 1): LengthAwarePaginator { $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'Content-Type' => 'application/json', 'X-Auth-Email' => $this->email, ])->get(static::CLOUDFLARE_URL.'/zones', [ @@ -66,7 +66,7 @@ public function getDomains(int $limit = 10, int $page = 1): LengthAwarePaginator public function deleteDnsRecord(string $domain, string $dnsRecordId): void { Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, ])->delete(static::CLOUDFLARE_URL."/zones/$domain/dns_records/$dnsRecordId"); } @@ -87,7 +87,7 @@ public function updateDomainNs(string $domain, array $nameservers): array public function createDomain(string $domain): array { $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, ])->post(static::CLOUDFLARE_URL.'/zones', [ 'account' => [ @@ -117,7 +117,7 @@ public function createDomain(string $domain): array public function getDns(string $domain, string $type = null, int $limit = 10, int $page = 1): LengthAwarePaginator { $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, ])->get(static::CLOUDFLARE_URL."/zones/$domain/dns_records", array_merge([ 'per_page' => $limit, @@ -148,7 +148,7 @@ public function getDns(string $domain, string $type = null, int $limit = 10, int public function createDnsRecord(string $domain, array $dnsRecordArray): void { $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, 'content-type' => 'application/json', ])->post(static::CLOUDFLARE_URL."/zones/$domain/dns_records", $dnsRecordArray); @@ -163,7 +163,7 @@ public function createDnsRecord(string $domain, array $dnsRecordArray): void public function hasEmailRouting(string $domain): bool { $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, 'content-type' => 'application/json', ])->get(static::CLOUDFLARE_URL."/zones/$domain/email/routing"); @@ -228,7 +228,7 @@ public function getAnalytics(Domain $domain, Carbon $startDate, Carbon $endDate) { $zone = $domain->cloudflare_id; $response = Http::withHeaders([ - 'Authorization' => 'bearer '.$this->apiKey, + 'X-Auth-Key' => $this->apiKey, 'x-auth-email' => $this->email, 'content-type' => 'application/json', ])->get(static::CLOUDFLARE_URL."/zones/$zone/dns_analytics/report/bytime?".http_build_query([ diff --git a/app/Services/Finance/PlaidService.php b/app/Services/Finance/PlaidService.php index c045e5f..a168286 100644 --- a/app/Services/Finance/PlaidService.php +++ b/app/Services/Finance/PlaidService.php @@ -256,4 +256,18 @@ public function updateWebhook(string $access_token): array ]) ->toArray(); } + + public function syncTransactions(string $access_token, string $cursor = null): array + { + return $this->http + ->{config('services.plaid.env')}() + ->post('/transactions/sync', array_merge([ + 'access_token' => $access_token, + 'client_id' => config('services.plaid.client_id'), + 'secret' => config('services.plaid.secret_key'), + ], empty($cursor) ? [] : [ + 'cursor' => $cursor, + ])) + ->toArray(); + } } diff --git a/app/Services/Geocoding/GoogleMapsGeocodingService.php b/app/Services/Geocoding/GoogleMapsGeocodingService.php new file mode 100644 index 0000000..d968aab --- /dev/null +++ b/app/Services/Geocoding/GoogleMapsGeocodingService.php @@ -0,0 +1,41 @@ +remember($address, now()->addDay(), fn () => $client->request('GET', $url)->getBody()->getContents()); + $response = json_decode($response); + + if ($response->status === 'ZERO_RESULTS') { + return ['latitude' => null, 'longitude' => null, 'address' => $address]; + } + + try { + $latitude = $response->results[0]->geometry->location->lat; + $longitude = $response->results[0]->geometry->location->lng; + + $address = $response->results[0]->formatted_address; + + return compact('latitude', 'longitude', 'address'); + } catch (\Throwable $e) { + info('Failed to gecode '.$address, [ + 'address' => $address, + 'exception' => $e, + ]); + + dd($response, $address); + } + } +} diff --git a/app/Services/ImapService.php b/app/Services/ImapService.php index 6a578f4..c86aaf9 100644 --- a/app/Services/ImapService.php +++ b/app/Services/ImapService.php @@ -4,21 +4,28 @@ namespace App\Services; +use App\Contracts\Services\ImapServiceContract; use Carbon\Carbon; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; -class ImapService +class ImapService implements ImapServiceContract { - public function findAllMailboxes() + public function findAllMailboxes(): Collection { return collect(imap_list($inbox = imap_open( $this->buildMailboxString(), env('IMAP_USERNAME'), env('IMAP_PASSWORD') ), $this->buildMailboxString(), '*')) - ->tap(fn () => imap_close($inbox)); + ->tap(fn () => imap_close($inbox)) + ->map(fn ($mailbox) => Str::of($mailbox) + ->replace('{proton-bridge:143/imap/notls}', '') + ->toString() + ) + ->filter(fn ($mailbox) => ! str_starts_with($mailbox, 'Labels')) + ->values(); } public function findAllFromDate(string $mailbox, Carbon $date): Collection @@ -49,6 +56,11 @@ public function findAllFromDate(string $mailbox, Carbon $date): Collection dd($headers); } + if (empty($headers['To'])) { + // ew + $headers['To'] = $headers['Delivered-To']; + } + return [ 'id' => imap_uid($inbox, $messageNumber), 'to' => $this->extractEmailAndName($headers['To']), @@ -98,6 +110,11 @@ public function findMessage(string $messageNumber, bool $peak = true): array $body = base64_encode(empty($message->textHtml) ? $message->textPlain : $message->textHtml); + if (empty($headers['To'])) { + // ew + $headers['To'] = $headers['Delivered-To']; + } + return [ 'id' => $messageNumber, 'to' => $this->extractEmailAndName($headers['To']), @@ -161,7 +178,7 @@ protected function extractEmailAndName(?string $value, $headers = []) }; } - public function markAsRead(string $messageId) + public function markAsRead(string $messageId): void { $mailbox = new \PhpImap\Mailbox( sprintf($this->buildMailboxString().'INBOX'), // IMAP server and mailbox folder @@ -173,10 +190,10 @@ public function markAsRead(string $messageId) false // Attachment filename mode (optional; false = random filename; true = original filename) ); - $mailbox->getMail($messageId, true); + $mailbox->getMail((int) $messageId, true); } - public function markAsUnread(string $messageId) + public function markAsUnread(string $messageId): void { $mailbox = new \PhpImap\Mailbox( sprintf($this->buildMailboxString().'INBOX'), // IMAP server and mailbox folder @@ -188,6 +205,6 @@ public function markAsUnread(string $messageId) false // Attachment filename mode (optional; false = random filename; true = original filename) ); - $mailbox->markMailAsUnread($messageId); + $mailbox->markMailAsUnread((int) $messageId); } } diff --git a/app/Services/Messaging/ImapCredentialService.php b/app/Services/Messaging/ImapCredentialService.php new file mode 100644 index 0000000..d58ea3b --- /dev/null +++ b/app/Services/Messaging/ImapCredentialService.php @@ -0,0 +1,224 @@ +buildMailboxString(), + $this->credential->settings['username'], + $this->credential->settings['password'] + ), $this->buildMailboxString(), '*')) + ->tap(fn () => imap_close($inbox)) + ->map(fn ($mailbox) => Str::of($mailbox) + // Remove the mailbox string + ->replace($this->buildMailboxString(), '') + ->toString() + ) + // Don't return mailboxes explicitly marked as labels.... GOOGLE... + ->filter(fn ($mailbox) => ! str_starts_with($mailbox, 'Labels')) + ->values(); + } + + public function findAllFromDate(string $mailbox, Carbon $date): Collection + { + return collect(imap_search($inbox = imap_open( + sprintf('%s%s', $this->buildMailboxString(), $mailbox), + $this->credential->settings['username'], + $this->credential->settings['password'], + ), + sprintf('SINCE "%s"', $date->format('Y-m-d')) + )) + ->map(function ($messageNumber) use ($inbox) { + $headers = Str::of($headerRaw = imap_fetchheader($inbox, $messageNumber)) + ->explode("\r\n") + ->filter() + ->reduce(fn ($lines, $line) => array_merge( + $lines, + [explode(': ', $line, 2)[0] => explode(': ', $line, 2)[1] ?? null] + ), []); + + $rfcHeaders = imap_rfc822_parse_headers($headerRaw); + $body = null; + $overview = Arr::first(imap_fetch_overview($inbox, (string) $messageNumber)); + + try { + Carbon::parse($headers['X-Pm-Date']); + } catch (\Throwable $e) { + dd($headers); + } + + if (empty($headers['To'])) { + // ew + $headers['To'] = $headers['Delivered-To']; + } + + // Many of these headers are probably specific to me using proton mail... It may not work for everyone... + return [ + 'id' => imap_uid($inbox, $messageNumber), + 'to' => $this->extractEmailAndName($headers['To']), + 'addressed-to' => $this->extractEmailAndName($headers['X-Simplelogin-Envelope-To'] ?? $headers['X-Original-To'] ?? null), + 'addressed-from' => $this->extractEmailAndName($rfcHeaders->fromaddress ?? $headers['X-Pm-External-Id'] ?? null), + 'date' => Carbon::parse($headers['X-Pm-Date']), + 'human_date' => Carbon::parse($headers['X-Pm-Date'])->fromNow(), + 'subject' => imap_utf8($headers['Subject']), + 'from' => $this->extractEmailAndName($rfcHeaders->senderaddress ?? $rfcHeaders->fromaddress ?? $headers['From'], $headers), + 'reply-to' => $this->extractEmailAndName($rfcHeaders->reply_toaddress ?? $headers['Reply-To']), + 'spam' => intval($headers['X-Pm-Spamscore'] ?? 0), + 'seen' => (bool) $overview->seen ?? false, + 'deleted' => (bool) $overview->deleted ?? false, + 'answered' => (bool) $overview->answered ?? false, + 'recent' => (bool) $overview->recent ?? false, + 'draft' => (bool) $overview->draft ?? false, + ]; + }) + ->sortByDesc('date') + ->values() + ->tap(fn () => imap_close($inbox)); + } + + public function findMessage(string $messageNumber, bool $peak = true): array + { + $mailbox = new \PhpImap\Mailbox( + sprintf($this->buildMailboxString().'INBOX'), // IMAP server and mailbox folder + $this->credential->settings['username'], + $this->credential->settings['password'], + storage_path(), // Directory, where attachments will be saved (optional) + 'UTF-8', // Server encoding (optional) + true, // Trim leading/ending whitespaces of IMAP path (optional) + false // Attachment filename mode (optional; false = random filename; true = original filename) + ); + + $message = $mailbox->getMail((int) $messageNumber, false); + + $headers = Str::of($message->headersRaw) + ->explode("\r\n") + ->filter() + ->reduce(fn ($lines, $line) => array_merge( + $lines, + [explode(': ', $line, 2)[0] => explode(': ', $line, 2)[1] ?? null] + ), []); + + $rfcHeaders = imap_rfc822_parse_headers($message->headersRaw); + + $body = base64_encode(empty($message->textHtml) ? $message->textPlain : $message->textHtml); + + if (empty($headers['To'])) { + // ew + $headers['To'] = $headers['Delivered-To']; + } + + return [ + 'id' => $messageNumber, + 'to' => $this->extractEmailAndName($headers['To']), + 'addressed-to' => $this->extractEmailAndName($headers['X-Simplelogin-Envelope-To'] ?? $headers['X-Original-To'] ?? null), + 'addressed-from' => $this->extractEmailAndName($rfcHeaders->fromaddress ?? $headers['X-Pm-External-Id'] ?? null), + 'date' => Carbon::parse($headers['X-Pm-Date']), + 'human_date' => Carbon::parse($headers['X-Pm-Date'])->fromNow(), + 'subject' => imap_utf8($headers['Subject']), + 'from' => $this->extractEmailAndName($rfcHeaders->senderaddress ?? $rfcHeaders->fromaddress ?? $headers['From'], $headers), + 'reply-to' => $this->extractEmailAndName($rfcHeaders->reply_toaddress ?? $headers['Reply-To']), + 'spam' => $headers['X-Pm-Spamscore'], + 'seen' => $message->isSeen ?? false, + 'deleted' => $message->isDeleted ?? false, + 'answered' => $message->isAnswered ?? false, + 'recent' => $message->isRecent ?? false, + 'draft' => $message->isDraft ?? false, + 'body' => $body, + 'view' => empty($message->textHtml) ? 'render-plain-inbox' : 'render-rich-inbox', + ]; + } + + protected function buildMailboxString() + { + return sprintf( + '{%s:%s/imap/%s}', + $this->credential->settings['host'], + $this->credential->settings['port'], + $this->credential->settings['encryption'], + ); + } + + protected function extractEmailAndName(?string $value, $headers = []) + { + if (empty($value)) { + return $value; + } + + if (! str_contains($value, '<')) { + return array_merge([ + 'email' => $value, + ]); + } + + if (str_contains($value, '"')) { + preg_match_all('/(\".*\")?(\s)?(\<.*\>)/', $value, $matches); + } else { + preg_match_all('/(.*)(\s)(\<.*\>)/', $value, $matches); + } + + return match (count($matches)) { + 3 => [ + 'email' => trim(Arr::first($matches[2])), + 'original' => $value, + ], + 4 => empty(trim((string) Arr::first($matches[1]), "\"'")) ? [ + // Address + 'email' => trim((string) Arr::first($matches[3]), '<>'), + 'original' => $value, + ] : [ + // Name + 'name' => trim(Arr::first($matches[1]), "\"'"), + // Address + 'email' => trim(Arr::first($matches[3]), '<>'), + 'original' => $value, + ], + }; + } + + public function markAsRead(string $messageId): void + { + $mailbox = new \PhpImap\Mailbox( + sprintf($this->buildMailboxString().'INBOX'), // IMAP server and mailbox folder + $this->credential->settings['username'], + $this->credential->settings['password'], + storage_path(), // Directory, where attachments will be saved (optional) + 'UTF-8', // Server encoding (optional) + true, // Trim leading/ending whitespaces of IMAP path (optional) + false // Attachment filename mode (optional; false = random filename; true = original filename) + ); + + $mailbox->getMail((int) $messageId, true); + } + + public function markAsUnread(string $messageId): void + { + $mailbox = new \PhpImap\Mailbox( + sprintf($this->buildMailboxString().'INBOX'), // IMAP server and mailbox folder + $this->credential->settings['username'], + $this->credential->settings['password'], + storage_path(), // Directory, where attachments will be saved (optional) + 'UTF-8', // Server encoding (optional) + true, // Trim leading/ending whitespaces of IMAP path (optional) + false // Attachment filename mode (optional; false = random filename; true = original filename) + ); + + $mailbox->markMailAsUnread((int) $messageId); + } +} diff --git a/app/Services/Messaging/ImapFactoryService.php b/app/Services/Messaging/ImapFactoryService.php new file mode 100644 index 0000000..5830178 --- /dev/null +++ b/app/Services/Messaging/ImapFactoryService.php @@ -0,0 +1,16 @@ +getUseVariables(); - if (empty($listenerInformation['listener'])) { - continue; - } + // Laravel puts all listeners inside of a closure. + $listenerInformation = (new \Laravel\SerializableClosure\Support\ReflectionClosure($listener))->getUseVariables(); + if (empty($listenerInformation['listener'])) { + continue; + } - array_push($actualListeners, $listenerInformation['listener']); + array_push($actualListeners, $listenerInformation['listener']); } $logicalEventReflection = new \ReflectionClass($instanceOfLogicalEvent); @@ -394,11 +393,11 @@ public static function findLogicalEvents(): array $traits = $logicalEventReflection->getTraits(); $methodsProvidedByTraits = array_reduce( - $traits, - fn ($methods, \ReflectionClass $trait) => array_merge( - $methods, array_map(fn (\ReflectionMethod $m) => $m->getName(), $trait->getMethods()) - ), - [] + $traits, + fn ($methods, \ReflectionClass $trait) => array_merge( + $methods, array_map(fn (\ReflectionMethod $m) => $m->getName(), $trait->getMethods()) + ), + [] ); $methodNamesOnClass = array_map(fn (\ReflectionMethod $method) => $method->getName(), $logicalEventReflection->getMethods()); @@ -408,34 +407,34 @@ public static function findLogicalEvents(): array $netteCodeInstance = static::for($instanceOfLogicalEvent)->getPrimaryClassType(); return array_merge($allClasses, [ - $instanceOfLogicalEvent => [ - 'listeners' => $actualListeners, - 'constructor' => array_map(function (\ReflectionParameter $param) use ($code) { - return [ - $param->getName() => $code->recurseGetUnionType($param->getType()), - ]; - }, $constructorParametersThatAreModels), - 'event' => $instanceOfLogicalEvent, - 'name' => class_basename($instanceOfLogicalEvent), - 'methods' => array_reduce($methodsActuallyDefinedOnOurLogicalEvent, function ($allMethods, $method) use ($netteCodeInstance) { - try { - $methodInstance = $netteCodeInstance->getMethod($method); - } catch (InvalidArgumentException $e) { - return $allMethods; - } - - return array_merge($allMethods, [ - $method => [ - 'parameters' => array_map(function (PromotedParameter $param) { - return trim($param->getType(), '\\'); - }, $methodInstance->getParameters()), - 'body' => $methodInstance->getBody(), - ], - ]); - }, []), - ], + $instanceOfLogicalEvent => [ + 'listeners' => $actualListeners, + 'constructor' => array_map(function (\ReflectionParameter $param) use ($code) { + return [ + $param->getName() => $code->recurseGetUnionType($param->getType()), + ]; + }, $constructorParametersThatAreModels), + 'event' => $instanceOfLogicalEvent, + 'name' => class_basename($instanceOfLogicalEvent), + 'methods' => array_reduce($methodsActuallyDefinedOnOurLogicalEvent, function ($allMethods, $method) use ($netteCodeInstance) { + try { + $methodInstance = $netteCodeInstance->getMethod($method); + } catch (InvalidArgumentException $e) { + return $allMethods; + } + + return array_merge($allMethods, [ + $method => [ + 'parameters' => array_map(function (PromotedParameter $param) { + return trim($param->getType(), '\\'); + }, $methodInstance->getParameters()), + 'body' => $methodInstance->getBody(), + ], + ]); + }, []), + ], ]); - }, [])); + }, [])); return collect($allEventsReducedWithContext) ->sortByDesc(fn ($e) => count($e['listeners'])) diff --git a/app/Services/Registrar/CloudflareRegistrarService.php b/app/Services/Registrar/CloudflareRegistrarService.php index ec53be4..aa1eef6 100644 --- a/app/Services/Registrar/CloudflareRegistrarService.php +++ b/app/Services/Registrar/CloudflareRegistrarService.php @@ -57,11 +57,11 @@ public function getDomains(int $limit = 10, int $page = 1): LengthAwarePaginator public function getDomainNs(string $domain): array { - // TODO: Implement getDomainNs() method. + return []; } public function updateDomainNs(string $domain, array $nameservers): array { - // TODO: Implement updateDomainNs() method. + return []; } } diff --git a/app/Services/Registrar/NamecheapService.php b/app/Services/Registrar/NamecheapService.php index 10cd1a6..c8801dd 100644 --- a/app/Services/Registrar/NamecheapService.php +++ b/app/Services/Registrar/NamecheapService.php @@ -52,7 +52,7 @@ public function getDomains(int $limit = 10, int $page = 1): LengthAwarePaginator // 'original' => (array) $domain, 'created_at' => Carbon::parse($domain->Created), 'expires_at' => Carbon::parse($domain->Expires), -// 'renews_at' => $this->fetchPriceOfRenewal($domain->Name), + // 'renews_at' => $this->fetchPriceOfRenewal($domain->Name), ], $domains), $domainResponse->CommandResponse->Paging->TotalItems ?? 0, $limit, diff --git a/app/Services/Weather/OpenWeatherService.php b/app/Services/Weather/OpenWeatherService.php index 24db067..728c4a6 100644 --- a/app/Services/Weather/OpenWeatherService.php +++ b/app/Services/Weather/OpenWeatherService.php @@ -52,6 +52,7 @@ public function query(string $address): array default => '❓', }; + $forecast->address = $address; $forecast->temperature = $weather->main->temp; $forecast->feels_like = $weather->main->feels_like; $forecast->humidity = $weather->main->humidity; diff --git a/app/Services/Weather/WeatherGovApiService.php b/app/Services/Weather/WeatherGovApiService.php new file mode 100644 index 0000000..2fde136 --- /dev/null +++ b/app/Services/Weather/WeatherGovApiService.php @@ -0,0 +1,22 @@ +geocodingService->geocode($address); + + } +} diff --git a/composer.json b/composer.json index 2a91bc7..5e98250 100644 --- a/composer.json +++ b/composer.json @@ -40,6 +40,7 @@ "spatie/laravel-ignition": "^2.2", "spatie/laravel-query-builder": "^5.2", "spatie/laravel-tags": "^4.4", + "staudenmeir/eloquent-json-relations": "^1.1", "tightenco/ziggy": "^1.0" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 0c6b685..661f1ed 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "23284d0d19d9010494a5537b7b3252d2", + "content-hash": "da3f33029a50aa5a1fecc0e46b6f408d", "packages": [ { "name": "bacon/bacon-qr-code", @@ -9479,6 +9479,109 @@ ], "time": "2023-07-19T19:21:38+00:00" }, + { + "name": "staudenmeir/eloquent-has-many-deep-contracts", + "version": "v1.1", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git", + "reference": "c39317b839d6123be126b9980e4a3d38310f5939" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/c39317b839d6123be126b9980e4a3d38310f5939", + "reference": "c39317b839d6123be126b9980e4a3d38310f5939", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentHasManyDeepContracts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Contracts for staudenmeir/eloquent-has-many-deep", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues", + "source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.1" + }, + "time": "2023-01-18T12:43:26+00:00" + }, + { + "name": "staudenmeir/eloquent-json-relations", + "version": "v1.9.1", + "source": { + "type": "git", + "url": "https://github.com/staudenmeir/eloquent-json-relations.git", + "reference": "c6b107d179888cc7b666766c3166bbcb01a94245" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staudenmeir/eloquent-json-relations/zipball/c6b107d179888cc7b666766c3166bbcb01a94245", + "reference": "c6b107d179888cc7b666766c3166bbcb01a94245", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0", + "php": "^8.1", + "staudenmeir/eloquent-has-many-deep-contracts": "^1.1" + }, + "require-dev": { + "barryvdh/laravel-ide-helper": "^2.13", + "orchestra/testbench": "^8.17", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.1", + "staudenmeir/eloquent-has-many-deep": "^1.18.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Staudenmeir\\EloquentJsonRelations\\IdeHelperServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Staudenmeir\\EloquentJsonRelations\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonas Staudenmeir", + "email": "mail@jonas-staudenmeir.de" + } + ], + "description": "Laravel Eloquent relationships with JSON keys", + "support": { + "issues": "https://github.com/staudenmeir/eloquent-json-relations/issues", + "source": "https://github.com/staudenmeir/eloquent-json-relations/tree/v1.9.1" + }, + "funding": [ + { + "url": "https://paypal.me/JonasStaudenmeir", + "type": "custom" + } + ], + "time": "2023-12-19T11:59:29+00:00" + }, { "name": "symfony/console", "version": "v6.4.3", diff --git a/config/app.php b/config/app.php index 63bb19c..5fd1355 100644 --- a/config/app.php +++ b/config/app.php @@ -167,7 +167,7 @@ */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, - // App\Providers\BroadcastServiceProvider::class, + App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, diff --git a/config/services.php b/config/services.php index dfefa4b..4071200 100644 --- a/config/services.php +++ b/config/services.php @@ -35,7 +35,7 @@ 'plaid' => [ 'env' => env('PLAID_ENV', 'sandbox'), - 'secret_key' => env('PLAID_SANDBOX_SECRET', ''), + 'secret_key' => env('PLAID_DEVELOPMENT_SECRET', ''), 'client_id' => env('PLAID_CLIENT_ID', ''), 'client_name' => env('APP_NAME'), 'language' => env('PLAID_LANGUAGE', 'en'), diff --git a/database/factories/Finance/TransactionFactory.php b/database/factories/Finance/TransactionFactory.php index 79e3460..331a69d 100644 --- a/database/factories/Finance/TransactionFactory.php +++ b/database/factories/Finance/TransactionFactory.php @@ -29,7 +29,9 @@ public function definition(): array 'category_id' => $this->faker->numberBetween(2, 1000), 'transaction_id' => Str::random(32), 'transaction_type' => 'depository', - 'pending_transaction_id' => null, + 'personal_finance_category' => 'GENERAL_MERCHANDISE', + 'personal_finance_category_detailed' => 'GENERAL_MERCHANDISE_SUPERSTORES', + 'personal_finance_icon' => 'https://plaid-category-icons.plaid.com/PFC_GENERAL_MERCHANDISE.png', 'seller_icon' => '', 'data' => '[]', ]; } diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php new file mode 100644 index 0000000..87867d8 --- /dev/null +++ b/database/factories/TaskFactory.php @@ -0,0 +1,25 @@ + + */ +class TaskFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + // + ]; + } +} diff --git a/database/migrations/2023_10_07_023041_create_messages_table.php b/database/migrations/2023_10_07_023041_create_messages_table.php index 028469f..b3548f4 100644 --- a/database/migrations/2023_10_07_023041_create_messages_table.php +++ b/database/migrations/2023_10_07_023041_create_messages_table.php @@ -15,7 +15,9 @@ public function up(): void { Schema::create('messages', function (Blueprint $table) { $table->id(); + $table->foreignIdFor(\App\Models\Credential::class); $table->foreignIdFor(\App\Models\Person::class, 'from_person'); + $table->foreignIdFor(\App\Models\Person::class, 'to_person'); $table->foreignIdFor(\App\Models\Thread::class)->nullable(); diff --git a/database/migrations/2023_10_15_205749_create_navigations_table.php b/database/migrations/2023_10_15_205749_create_navigations_table.php index c721a02..36ad111 100644 --- a/database/migrations/2023_10_15_205749_create_navigations_table.php +++ b/database/migrations/2023_10_15_205749_create_navigations_table.php @@ -33,7 +33,7 @@ public function up(): void \App\Models\Navigation::create([ 'name' => 'Dashboard', 'icon' => 'HomeIcon', - 'href' => '/-', + 'href' => '/-/dashboard', 'order' => 0, 'authentication_required' => true, ]); @@ -122,14 +122,6 @@ public function up(): void 'value' => 'dev,local', ]); - \App\Models\Navigation::create([ - 'name' => 'Banking', - 'icon' => 'WalletIcon', - 'href' => '/-/banking', - 'order' => 6, - 'authentication_required' => true, - ]); - \App\Models\Navigation::create([ 'name' => 'Login', 'href' => '/login', diff --git a/database/migrations/2024_01_15_174624_create_tasks_table.php b/database/migrations/2024_01_15_174624_create_tasks_table.php new file mode 100644 index 0000000..b5b0411 --- /dev/null +++ b/database/migrations/2024_01_15_174624_create_tasks_table.php @@ -0,0 +1,39 @@ +id(); + $table->string('name'); + + // daily, habit, todo + $table->string('type'); + + $table->json('checklist')->nullable(); + $table->text('notes')->nullable(); + + $table->dateTime('start_date')->nullable(); + $table->dateTime('end_date')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tasks'); + } +}; diff --git a/database/migrations/2024_01_17_063915_modify_messages_column.php b/database/migrations/2024_01_17_063915_modify_messages_column.php new file mode 100644 index 0000000..d9edee6 --- /dev/null +++ b/database/migrations/2024_01_17_063915_modify_messages_column.php @@ -0,0 +1,39 @@ +string('subject', 255)->nullable(); + $table->boolean('seen')->default(false); + $table->boolean('spam')->default(false); + $table->boolean('answered')->default(false); + $table->longText('message')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + \App\Models\Message::query()->truncate(); + Schema::table('messages', function (Blueprint $table) { + $table->dropColumn('subject'); + $table->dropColumn('seen'); + $table->dropColumn('spam'); + $table->dropColumn('answered'); + $table->text('message')->change(); + }); + } +}; diff --git a/database/migrations/2024_01_18_034524_add_emails_to_messages.php b/database/migrations/2024_01_18_034524_add_emails_to_messages.php new file mode 100644 index 0000000..ce345bd --- /dev/null +++ b/database/migrations/2024_01_18_034524_add_emails_to_messages.php @@ -0,0 +1,32 @@ +string('to_email', 255)->nullable()->after('from_person'); + $table->string('from_email', 255)->nullable()->after('from_person'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('messages', function (Blueprint $table) { + $table->dropColumn('to_email'); + $table->dropColumn('from_email'); + }); + } +}; diff --git a/database/migrations/2024_01_22_002543_rebuild_transactions_table.php b/database/migrations/2024_01_22_002543_rebuild_transactions_table.php new file mode 100644 index 0000000..7b5246d --- /dev/null +++ b/database/migrations/2024_01_22_002543_rebuild_transactions_table.php @@ -0,0 +1,62 @@ +id(); + $table->string('transaction_id')->collation('utf8_bin')->unique(); + $table->string('name')->nullable()->index(); + $table->double('amount', 13, 2)->nullable(); + $table->string('account_id')->nullable()->index(); + + $table->date('date')->nullable(); + + $table->boolean('pending')->default(false); + $table->integer('category_id')->unsigned()->nullable(); + $table->string('transaction_type')->nullable(); + $table->string('personal_finance_category')->nullable(); + $table->string('personal_finance_category_detailed')->nullable(); + $table->string('personal_finance_icon', 2048)->nullable(); + $table->string('seller_icon', 2048)->nullable(); + + $table->json('data')->nullable(); + $table->timestamps(); + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('transactions'); + Schema::create('transactions', function (Blueprint $table) { + $table->id(); + $table->string('name')->nullable()->index(); + $table->double('amount', 13, 2)->nullable(); + $table->string('account_id')->nullable()->index(); + $table->date('date')->nullable(); + $table->boolean('pending')->default(false); + $table->integer('category_id')->unsigned()->nullable(); + $table->string('transaction_id')->nullable()->index(); + $table->string('transaction_type')->nullable(); + $table->string('pending_transaction_id')->nullable(); + $table->json('data')->nullable(); + $table->timestamps(); + }); + + } +}; diff --git a/database/seeders/TaskSeeder.php b/database/seeders/TaskSeeder.php new file mode 100644 index 0000000..cf5555e --- /dev/null +++ b/database/seeders/TaskSeeder.php @@ -0,0 +1,18 @@ +
- +
fallback: {{ datum}}
@@ -119,9 +119,9 @@ -
-
-
+
+
+
Create Modal
- -
- +
+ + Close + + - Save - + primary + medium + > + Save +
-
+
diff --git a/resources/js/Components/Spork/Finance/LinkAccount.vue b/resources/js/Components/Spork/Finance/LinkAccount.vue index 60dcdcd..3616505 100644 --- a/resources/js/Components/Spork/Finance/LinkAccount.vue +++ b/resources/js/Components/Spork/Finance/LinkAccount.vue @@ -10,8 +10,8 @@
{{ account.name }}
- ${{ account.available }} - / ${{ account.balance }} + ${{ account.available.toLocaleString() }} + / ${{ account.balance.toLocaleString() }}
from {{account.credential.name}}
diff --git a/resources/js/Components/Spork/SporkDynamicInput.vue b/resources/js/Components/Spork/SporkDynamicInput.vue index b253df3..f982fbf 100644 --- a/resources/js/Components/Spork/SporkDynamicInput.vue +++ b/resources/js/Components/Spork/SporkDynamicInput.vue @@ -1,12 +1,14 @@ - diff --git a/resources/js/Components/Spork/SporkSelect.vue b/resources/js/Components/Spork/SporkSelect.vue index a6136c3..d76f612 100644 --- a/resources/js/Components/Spork/SporkSelect.vue +++ b/resources/js/Components/Spork/SporkSelect.vue @@ -2,7 +2,7 @@ -
+
+ +
+
-
-
-
- {{ log.log_name}} - {{ log.description}} - {{ log.causer_type}} - {{ log.subject_type}} -
-
+
+ +
{{ {unread_messages, tasks_today } }}
diff --git a/resources/js/Pages/Finance/Index.vue b/resources/js/Pages/Finance/Index.vue index aa35319..48b4d30 100644 --- a/resources/js/Pages/Finance/Index.vue +++ b/resources/js/Pages/Finance/Index.vue @@ -29,7 +29,7 @@ const transactionHeaders = [ }, { name: 'Date', - accessor: (value) => value?.date ? dayjs(value.date) : null + accessor: (value) => value?.date ? dayjs(value.date).format("MMM DD, YYYY") : null }, { name : 'Tags', diff --git a/resources/js/Pages/Logic/Index.vue b/resources/js/Pages/Logic/Index.vue index 19fcf95..d47e4d6 100644 --- a/resources/js/Pages/Logic/Index.vue +++ b/resources/js/Pages/Logic/Index.vue @@ -48,10 +48,10 @@ const removeListenerForEvent = async ({ event }, listener) => {
{{ event.event }}
-
-
Constructor Parameters
-
-
+
+
Constructor
+
+
{{ type }}
@@ -59,21 +59,19 @@ const removeListenerForEvent = async ({ event }, listener) => {
-
- Nothing is in the constructor -
+
+ Nothing is in the constructor +
-
Class Methods
{{ method }}
- +
-
-
Classes listening for event
+
{{ listener }}::class @@ -81,8 +79,8 @@ const removeListenerForEvent = async ({ event }, listener) => {
-
- Nothing is listening for this event +
+ No listeners are defined for this event
@@ -110,11 +108,6 @@ const removeListenerForEvent = async ({ event }, listener) => {
-
- - Save in Project - -
diff --git a/resources/js/Pages/Manage/Index.vue b/resources/js/Pages/Manage/Index.vue index ad14ebc..80aff5a 100644 --- a/resources/js/Pages/Manage/Index.vue +++ b/resources/js/Pages/Manage/Index.vue @@ -9,6 +9,7 @@ import SporkInput from "@/Components/Spork/SporkInput.vue"; import CrudView from "@/Components/Spork/CrudView.vue"; import { buildUrl } from '@kbco/query-builder'; import Manage from "@/Layouts/Manage.vue"; +import SporkDynamicInput from "@/Components/Spork/SporkDynamicInput.vue"; const page = usePage() const { title, data, description, paginator, link, apiLink, body } = defineProps({ data: Array, @@ -20,7 +21,22 @@ const { title, data, description, paginator, link, apiLink, body } = defineProps body: String, apiLink: String, }) -const form = ref({}); + +const FillableArrayToDynamicForm = function (fillable) { + return fillable.map(value => ({ + value: '', + name: value, + })); +} + +const DynamicFormToFillableArray = function (model) { + return Object.keys(model).map(key => ({ + value: typeof (model[key] ?? '') === 'object' ? JSON.stringify(model[key]?? '') : (model[key] ?? ''), + name: key, + })); +} + +const form = ref(FillableArrayToDynamicForm(description.fillable)); const fetch = async (options) => { const response = await axios.get(buildUrl(apiLink, { @@ -45,7 +61,7 @@ const onSave = () => {} const possibleDescriptionForData = (data) => { const fieldsToUse = description?.fields?.filter(field => ![ - 'id', 'name', 'user_id', 'created_at', 'updated_at', 'icon', 'href', 'order', + 'id', 'name', 'user_id', 'created_at', 'updated_at', 'icon', 'href', 'order', 'value,' ]?.includes(field) && !field.endsWith('_id') && typeof data[field] !== 'boolean') .filter(field => data[field]); @@ -56,6 +72,7 @@ const possibleRelations = (data) => { return fieldsToUse; } +const log = console.log;