feat: Decrypt encrypted transactions on key unlock (#123)
## Summary - Add `GET /api/transactions?encrypted=true` endpoint for paginated listing of encrypted transactions - Add `PATCH /api/transactions/bulk` endpoint for batch-updating decrypted transaction data (max 50 per request, bypasses model events) - Add `useDecryptTransactions` hook that mirrors the existing account name decryption flow: fetches encrypted transactions page-by-page, decrypts `description`/`notes` client-side via Web Crypto API, sends plaintext back and clears IVs - Wire the hook into `AppSidebarLayout` so decryption runs automatically when the user unlocks their encryption key - Once all transactions (and accounts) are decrypted, the encryption key button in the header disappears automatically ## Test plan - [x] 11 Pest tests covering encrypted/plaintext filtering, pagination, user scoping, bulk update, authorization, validation, nullable notes, guest access, and no-model-events behavior - [x] Manual: create encrypted transactions, unlock encryption key, verify transactions get decrypted and the key button disappears
This commit is contained in:
parent
0e0a5c25fb
commit
6abec95d0e
|
|
@ -0,0 +1,329 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.17
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/cashier (CASHIER) - v16
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/pennant (PENNANT) - v1
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/wayfinder (WAYFINDER) - v0
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- @inertiajs/react (INERTIA) - v2
|
||||
- react (REACT) - v19
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
- @laravel/vite-plugin-wayfinder (WAYFINDER) - v0
|
||||
- eslint (ESLINT) - v9
|
||||
- prettier (PRETTIER) - v3
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
- `pennant-development` — Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling feature flags; showing or hiding features conditionally; implementing A/B testing; working with @feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional features, rollouts, or gradually enabling features.
|
||||
- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions.
|
||||
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
|
||||
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
|
||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
|
||||
|
||||
## URLs
|
||||
|
||||
- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
|
||||
- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
|
||||
- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
|
||||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Enums
|
||||
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
## Comments
|
||||
|
||||
- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
|
||||
|
||||
## PHPDoc Blocks
|
||||
|
||||
- Add useful array shape type definitions when appropriate.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== inertia-laravel/core rules ===
|
||||
|
||||
# Inertia
|
||||
|
||||
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
|
||||
- Components live in `resources/js/Pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
|
||||
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
|
||||
- IMPORTANT: Activate `inertia-react-development` when working with Inertia client-side patterns.
|
||||
|
||||
=== inertia-laravel/v2 rules ===
|
||||
|
||||
# Inertia v2
|
||||
|
||||
- Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach.
|
||||
- New features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, prefetching.
|
||||
- When using deferred props, add an empty state with a pulsing or animated skeleton.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
## Database
|
||||
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries.
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## Controllers & Validation
|
||||
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Queues
|
||||
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
## Configuration
|
||||
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
# Laravel 12
|
||||
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
## Laravel 12 Structure
|
||||
|
||||
- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
|
||||
- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
## Database
|
||||
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
=== pennant/core rules ===
|
||||
|
||||
# Laravel Pennant
|
||||
|
||||
- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Pennant documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pennant-development` every time you're working with a Pennant or feature-flag-related task.
|
||||
|
||||
=== wayfinder/core rules ===
|
||||
|
||||
# Laravel Wayfinder
|
||||
|
||||
Wayfinder generates TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes).
|
||||
|
||||
- IMPORTANT: Activate `wayfinder-development` skill whenever referencing backend routes in frontend components.
|
||||
- Invokable Controllers: `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
|
||||
- Parameter Binding: Detects route keys (`{post:slug}`) — `show({ slug: "my-post" })`.
|
||||
- Query Merging: `show(1, { mergeQuery: { page: 2, sort: null } })` merges with current URL, `null` removes params.
|
||||
- Inertia: Use `.form()` with `<Form>` component or `form.submit(store())` with useForm.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
|
||||
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
|
||||
- Do NOT delete tests without approval.
|
||||
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
|
||||
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
|
||||
|
||||
=== inertia-react/core rules ===
|
||||
|
||||
# Inertia + React
|
||||
|
||||
- IMPORTANT: Activate `inertia-react-development` when working with Inertia React client-side patterns.
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
# Tailwind CSS
|
||||
|
||||
- Always use existing Tailwind conventions; check project patterns before adding new ones.
|
||||
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
|
||||
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
|
||||
|
||||
=== laravel/fortify rules ===
|
||||
|
||||
## Laravel Fortify
|
||||
|
||||
Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
|
||||
|
||||
**Before implementing any authentication features, use the `search-docs` tool to get the latest docs for that specific feature.**
|
||||
|
||||
### Configuration & Setup
|
||||
|
||||
- Check `config/fortify.php` to see what's enabled. Use `search-docs` for detailed information on specific features.
|
||||
- Enable features by adding them to the `'features' => []` array: `Features::registration()`, `Features::resetPasswords()`, etc.
|
||||
- To see the all Fortify registered routes, use the `list-routes` tool with the `only_vendor: true` and `action: "Fortify"` parameters.
|
||||
- Fortify includes view routes by default (login, register). Set `'views' => false` in the configuration file to disable them if you're handling views yourself.
|
||||
|
||||
### Customization
|
||||
|
||||
- Views can be customized in `FortifyServiceProvider`'s `boot()` method using `Fortify::loginView()`, `Fortify::registerView()`, etc.
|
||||
- Customize authentication logic with `Fortify::authenticateUsing()` for custom user retrieval / validation.
|
||||
- Actions in `app/Actions/Fortify/` handle business logic (user creation, password reset, etc.). They're fully customizable, so you can modify them to change feature behavior.
|
||||
|
||||
## Available Features
|
||||
|
||||
- `Features::registration()` for user registration.
|
||||
- `Features::emailVerification()` to verify new user emails.
|
||||
- `Features::twoFactorAuthentication()` for 2FA with QR codes and recovery codes.
|
||||
- Add options: `['confirmPassword' => true, 'confirm' => true]` to require password confirmation and OTP confirmation before enabling 2FA.
|
||||
- `Features::updateProfileInformation()` to let users update their profile.
|
||||
- `Features::updatePasswords()` to let users change their passwords.
|
||||
- `Features::resetPasswords()` for password reset via email.
|
||||
</laravel-boost-guidelines>
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
---
|
||||
name: inertia-react-development
|
||||
description: >-
|
||||
Develops Inertia.js v2 React client-side applications. Activates when creating
|
||||
React pages, forms, or navigation; using <Link>, <Form>, useForm, or router;
|
||||
working with deferred props, prefetching, or polling; or when user mentions
|
||||
React with Inertia, React pages, React forms, or React navigation.
|
||||
---
|
||||
|
||||
# Inertia React Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating or modifying React page components for Inertia
|
||||
- Working with forms in React (using `<Form>` or `useForm`)
|
||||
- Implementing client-side navigation with `<Link>` or `router`
|
||||
- Using v2 features: deferred props, prefetching, or polling
|
||||
- Building React-specific features with the Inertia protocol
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Inertia v2 React patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Page Components Location
|
||||
|
||||
React page components should be placed in the `resources/js/Pages` directory.
|
||||
|
||||
### Page Component Structure
|
||||
|
||||
<code-snippet name="Basic React Page Component" lang="react">
|
||||
|
||||
export default function UsersIndex({ users }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
<ul>
|
||||
{users.map(user => <li key={user.id}>{user.name}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Client-Side Navigation
|
||||
|
||||
### Basic Link Component
|
||||
|
||||
Use `<Link>` for client-side navigation instead of traditional `<a>` tags:
|
||||
|
||||
<code-snippet name="Inertia React Navigation" lang="react">
|
||||
|
||||
import { Link, router } from '@inertiajs/react'
|
||||
|
||||
<Link href="/">Home</Link>
|
||||
<Link href="/users">Users</Link>
|
||||
<Link href={`/users/${user.id}`}>View User</Link>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Link with Method
|
||||
|
||||
<code-snippet name="Link with POST Method" lang="react">
|
||||
|
||||
import { Link } from '@inertiajs/react'
|
||||
|
||||
<Link href="/logout" method="post" as="button">
|
||||
Logout
|
||||
</Link>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Prefetching
|
||||
|
||||
Prefetch pages to improve perceived performance:
|
||||
|
||||
<code-snippet name="Prefetch on Hover" lang="react">
|
||||
|
||||
import { Link } from '@inertiajs/react'
|
||||
|
||||
<Link href="/users" prefetch>
|
||||
Users
|
||||
</Link>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
<code-snippet name="Router Visit" lang="react">
|
||||
|
||||
import { router } from '@inertiajs/react'
|
||||
|
||||
function handleClick() {
|
||||
router.visit('/users')
|
||||
}
|
||||
|
||||
// Or with options
|
||||
router.visit('/users', {
|
||||
method: 'post',
|
||||
data: { name: 'John' },
|
||||
onSuccess: () => console.log('Success!'),
|
||||
})
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Form Handling
|
||||
|
||||
### Form Component (Recommended)
|
||||
|
||||
The recommended way to build forms is with the `<Form>` component:
|
||||
|
||||
<code-snippet name="Form Component Example" lang="react">
|
||||
|
||||
import { Form } from '@inertiajs/react'
|
||||
|
||||
export default function CreateUser() {
|
||||
return (
|
||||
<Form action="/users" method="post">
|
||||
{({ errors, processing, wasSuccessful }) => (
|
||||
<>
|
||||
<input type="text" name="name" />
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
|
||||
<input type="email" name="email" />
|
||||
{errors.email && <div>{errors.email}</div>}
|
||||
|
||||
<button type="submit" disabled={processing}>
|
||||
{processing ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
|
||||
{wasSuccessful && <div>User created!</div>}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Form Component With All Props
|
||||
|
||||
<code-snippet name="Form Component Full Example" lang="react">
|
||||
|
||||
import { Form } from '@inertiajs/react'
|
||||
|
||||
<Form action="/users" method="post">
|
||||
{({
|
||||
errors,
|
||||
hasErrors,
|
||||
processing,
|
||||
progress,
|
||||
wasSuccessful,
|
||||
recentlySuccessful,
|
||||
clearErrors,
|
||||
resetAndClearErrors,
|
||||
defaults,
|
||||
isDirty,
|
||||
reset,
|
||||
submit
|
||||
}) => (
|
||||
<>
|
||||
<input type="text" name="name" defaultValue={defaults.name} />
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
|
||||
<button type="submit" disabled={processing}>
|
||||
{processing ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
{progress && (
|
||||
<progress value={progress.percentage} max="100">
|
||||
{progress.percentage}%
|
||||
</progress>
|
||||
)}
|
||||
|
||||
{wasSuccessful && <div>Saved!</div>}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Form Component Reset Props
|
||||
|
||||
The `<Form>` component supports automatic resetting:
|
||||
|
||||
- `resetOnError` - Reset form data when the request fails
|
||||
- `resetOnSuccess` - Reset form data when the request succeeds
|
||||
- `setDefaultsOnSuccess` - Update default values on success
|
||||
|
||||
Use the `search-docs` tool with a query of `form component resetting` for detailed guidance.
|
||||
|
||||
<code-snippet name="Form with Reset Props" lang="react">
|
||||
|
||||
import { Form } from '@inertiajs/react'
|
||||
|
||||
<Form
|
||||
action="/users"
|
||||
method="post"
|
||||
resetOnSuccess
|
||||
setDefaultsOnSuccess
|
||||
>
|
||||
{({ errors, processing, wasSuccessful }) => (
|
||||
<>
|
||||
<input type="text" name="name" />
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
|
||||
<button type="submit" disabled={processing}>
|
||||
Submit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Forms can also be built using the `useForm` helper for more programmatic control. Use the `search-docs` tool with a query of `useForm helper` for guidance.
|
||||
|
||||
### `useForm` Hook
|
||||
|
||||
For more programmatic control or to follow existing conventions, use the `useForm` hook:
|
||||
|
||||
<code-snippet name="useForm Hook Example" lang="react">
|
||||
|
||||
import { useForm } from '@inertiajs/react'
|
||||
|
||||
export default function CreateUser() {
|
||||
const { data, setData, post, processing, errors, reset } = useForm({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault()
|
||||
post('/users', {
|
||||
onSuccess: () => reset('password'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<input
|
||||
type="text"
|
||||
value={data.name}
|
||||
onChange={e => setData('name', e.target.value)}
|
||||
/>
|
||||
{errors.name && <div>{errors.name}</div>}
|
||||
|
||||
<input
|
||||
type="email"
|
||||
value={data.email}
|
||||
onChange={e => setData('email', e.target.value)}
|
||||
/>
|
||||
{errors.email && <div>{errors.email}</div>}
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={data.password}
|
||||
onChange={e => setData('password', e.target.value)}
|
||||
/>
|
||||
{errors.password && <div>{errors.password}</div>}
|
||||
|
||||
<button type="submit" disabled={processing}>
|
||||
Create User
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Inertia v2 Features
|
||||
|
||||
### Deferred Props
|
||||
|
||||
Use deferred props to load data after initial page render:
|
||||
|
||||
<code-snippet name="Deferred Props with Empty State" lang="react">
|
||||
|
||||
export default function UsersIndex({ users }) {
|
||||
// users will be undefined initially, then populated
|
||||
return (
|
||||
<div>
|
||||
<h1>Users</h1>
|
||||
{!users ? (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{users.map(user => (
|
||||
<li key={user.id}>{user.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Polling
|
||||
|
||||
Automatically refresh data at intervals:
|
||||
|
||||
<code-snippet name="Polling Example" lang="react">
|
||||
|
||||
import { router } from '@inertiajs/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Dashboard({ stats }) {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
router.reload({ only: ['stats'] })
|
||||
}, 5000) // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<div>Active Users: {stats.activeUsers}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### WhenVisible (Infinite Scroll)
|
||||
|
||||
Load more data when user scrolls to a specific element:
|
||||
|
||||
<code-snippet name="Infinite Scroll with WhenVisible" lang="react">
|
||||
|
||||
import { WhenVisible } from '@inertiajs/react'
|
||||
|
||||
export default function UsersList({ users }) {
|
||||
return (
|
||||
<div>
|
||||
{users.data.map(user => (
|
||||
<div key={user.id}>{user.name}</div>
|
||||
))}
|
||||
|
||||
{users.next_page_url && (
|
||||
<WhenVisible
|
||||
data="users"
|
||||
params={{ page: users.current_page + 1 }}
|
||||
fallback={<div>Loading more...</div>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using traditional `<a>` links instead of Inertia's `<Link>` component (breaks SPA behavior)
|
||||
- Forgetting to add loading states (skeleton screens) when using deferred props
|
||||
- Not handling the `undefined` state of deferred props before data loads
|
||||
- Using `<form>` without preventing default submission (use `<Form>` component or `e.preventDefault()`)
|
||||
- Forgetting to check if `<Form>` component is available in your Inertia version
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
name: pennant-development
|
||||
description: >-
|
||||
Manages feature flags with Laravel Pennant. Activates when creating, checking, or toggling
|
||||
feature flags; showing or hiding features conditionally; implementing A/B testing; working with
|
||||
@feature directive; or when the user mentions feature flags, feature toggles, Pennant, conditional
|
||||
features, rollouts, or gradually enabling features.
|
||||
---
|
||||
|
||||
# Pennant Features
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating or checking feature flags
|
||||
- Managing feature rollouts
|
||||
- Implementing A/B testing
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pennant patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Defining Features
|
||||
|
||||
<code-snippet name="Defining Features" lang="php">
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
Feature::define('new-dashboard', function (User $user) {
|
||||
return $user->isAdmin();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Checking Features
|
||||
|
||||
<code-snippet name="Checking Features" lang="php">
|
||||
if (Feature::active('new-dashboard')) {
|
||||
// Feature is active
|
||||
}
|
||||
|
||||
// With scope
|
||||
if (Feature::for($user)->active('new-dashboard')) {
|
||||
// Feature is active for this user
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
### Blade Directive
|
||||
|
||||
<code-snippet name="Blade Directive" lang="blade">
|
||||
@feature('new-dashboard')
|
||||
<x-new-dashboard />
|
||||
@else
|
||||
<x-old-dashboard />
|
||||
@endfeature
|
||||
</code-snippet>
|
||||
|
||||
### Activating / Deactivating
|
||||
|
||||
<code-snippet name="Activating Features" lang="php">
|
||||
Feature::activate('new-dashboard');
|
||||
Feature::for($user)->activate('new-dashboard');
|
||||
</code-snippet>
|
||||
|
||||
## Verification
|
||||
|
||||
1. Check feature flag is defined
|
||||
2. Test with different scopes/users
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Forgetting to scope features for specific users/entities
|
||||
- Not following existing naming conventions
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
name: pest-testing
|
||||
description: >-
|
||||
Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature
|
||||
tests, adding assertions, testing Livewire components, browser testing, debugging test failures,
|
||||
working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion,
|
||||
coverage, or needs to verify functionality works.
|
||||
---
|
||||
|
||||
# Pest Testing 4
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Creating new tests (unit, feature, or browser)
|
||||
- Modifying existing tests
|
||||
- Debugging test failures
|
||||
- Working with browser testing or smoke testing
|
||||
- Writing architecture tests or visual regression tests
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Pest 4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Creating Tests
|
||||
|
||||
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
|
||||
|
||||
### Test Organization
|
||||
|
||||
- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
|
||||
- Browser tests: `tests/Browser/` directory.
|
||||
- Do NOT remove tests without approval - these are core application code.
|
||||
|
||||
### Basic Test Structure
|
||||
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
|
||||
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
|
||||
- Run all tests: `php artisan test --compact`.
|
||||
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
|
||||
## Assertions
|
||||
|
||||
Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
|
||||
|
||||
<code-snippet name="Pest Response Assertion" lang="php">
|
||||
|
||||
it('returns all', function () {
|
||||
$this->postJson('/api/docs', [])->assertSuccessful();
|
||||
});
|
||||
|
||||
</code-snippet>
|
||||
|
||||
| Use | Instead of |
|
||||
|-----|------------|
|
||||
| `assertSuccessful()` | `assertStatus(200)` |
|
||||
| `assertNotFound()` | `assertStatus(404)` |
|
||||
| `assertForbidden()` | `assertStatus(403)` |
|
||||
|
||||
## Mocking
|
||||
|
||||
Import mock function before use: `use function Pest\Laravel\mock;`
|
||||
|
||||
## Datasets
|
||||
|
||||
Use datasets for repetitive tests (validation rules, etc.):
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Pest 4 Features
|
||||
|
||||
| Feature | Purpose |
|
||||
|---------|---------|
|
||||
| Browser Testing | Full integration tests in real browsers |
|
||||
| Smoke Testing | Validate multiple pages quickly |
|
||||
| Visual Regression | Compare screenshots for visual changes |
|
||||
| Test Sharding | Parallel CI runs |
|
||||
| Architecture Testing | Enforce code conventions |
|
||||
|
||||
### Browser Test Example
|
||||
|
||||
Browser tests run in real browsers for full integration testing:
|
||||
|
||||
- Browser tests live in `tests/Browser/`.
|
||||
- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
|
||||
- Use `RefreshDatabase` for clean state per test.
|
||||
- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
|
||||
- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
|
||||
- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging.
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in');
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!');
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Smoke Testing
|
||||
|
||||
Quickly validate multiple pages have no JavaScript errors:
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Visual Regression Testing
|
||||
|
||||
Capture and compare screenshots to detect visual changes.
|
||||
|
||||
### Test Sharding
|
||||
|
||||
Split tests across parallel processes for faster CI runs.
|
||||
|
||||
### Architecture Testing
|
||||
|
||||
Pest 4 includes architecture testing (from Pest 3):
|
||||
|
||||
<code-snippet name="Architecture Test Example" lang="php">
|
||||
|
||||
arch('controllers')
|
||||
->expect('App\Http\Controllers')
|
||||
->toExtendNothing()
|
||||
->toHaveSuffix('Controller');
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Not importing `use function Pest\Laravel\mock;` before using mock
|
||||
- Using `assertStatus(200)` instead of `assertSuccessful()`
|
||||
- Forgetting datasets for repetitive validation tests
|
||||
- Deleting tests without approval
|
||||
- Forgetting `assertNoJavascriptErrors()` in browser tests
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
---
|
||||
name: tailwindcss-development
|
||||
description: >-
|
||||
Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components,
|
||||
working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors,
|
||||
typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle,
|
||||
hero section, cards, buttons, or any visual/UI changes.
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate this skill when:
|
||||
|
||||
- Adding styles to components or pages
|
||||
- Working with responsive design
|
||||
- Implementing dark mode
|
||||
- Extracting repeated patterns into components
|
||||
- Debugging spacing or layout issues
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<code-snippet name="CSS-First Config" lang="css">
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="v4 Import Syntax" lang="diff">
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<code-snippet name="Gap Utilities" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<code-snippet name="Dark Mode" lang="html">
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<code-snippet name="Flexbox Layout" lang="html">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<code-snippet name="Grid Layout" lang="html">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
---
|
||||
name: wayfinder-development
|
||||
description: >-
|
||||
Activates whenever referencing backend routes in frontend components. Use when
|
||||
importing from @/actions or @/routes, calling Laravel routes from TypeScript,
|
||||
or working with Wayfinder route functions.
|
||||
---
|
||||
|
||||
# Wayfinder Development
|
||||
|
||||
## When to Apply
|
||||
|
||||
Activate whenever referencing backend routes in frontend components:
|
||||
- Importing from `@/actions/` or `@/routes/`
|
||||
- Calling Laravel routes from TypeScript/JavaScript
|
||||
- Creating links or navigation to backend endpoints
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Wayfinder patterns and documentation.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Generate Routes
|
||||
|
||||
Run after route changes if Vite plugin isn't installed:
|
||||
|
||||
php artisan wayfinder:generate --no-interaction
|
||||
|
||||
For form helpers, use `--with-form` flag:
|
||||
|
||||
php artisan wayfinder:generate --with-form --no-interaction
|
||||
|
||||
### Import Patterns
|
||||
|
||||
<code-snippet name="Controller Action Imports" lang="typescript">
|
||||
|
||||
// Named imports for tree-shaking (preferred)...
|
||||
import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
|
||||
|
||||
// Named route imports...
|
||||
import { show as postShow } from '@/routes/post'
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Common Methods
|
||||
|
||||
<code-snippet name="Wayfinder Methods" lang="typescript">
|
||||
|
||||
// Get route object...
|
||||
show(1) // { url: "/posts/1", method: "get" }
|
||||
|
||||
// Get URL string...
|
||||
show.url(1) // "/posts/1"
|
||||
|
||||
// Specific HTTP methods...
|
||||
show.get(1)
|
||||
store.post()
|
||||
update.patch(1)
|
||||
destroy.delete(1)
|
||||
|
||||
// Form attributes for HTML forms...
|
||||
store.form() // { action: "/posts", method: "post" }
|
||||
|
||||
// Query parameters...
|
||||
show(1, { query: { page: 1 } }) // "/posts/1?page=1"
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Wayfinder + Inertia
|
||||
|
||||
Use Wayfinder with the `<Form>` component:
|
||||
<code-snippet name="Wayfinder Form (React)" lang="typescript">
|
||||
|
||||
<Form {...store.form()}><input name="title" /></Form>
|
||||
|
||||
</code-snippet>
|
||||
|
||||
## Verification
|
||||
|
||||
1. Run `php artisan wayfinder:generate` to regenerate routes if Vite plugin isn't installed
|
||||
2. Check TypeScript imports resolve correctly
|
||||
3. Verify route URLs match expected paths
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using default imports instead of named imports (breaks tree-shaking)
|
||||
- Forgetting to regenerate after route changes
|
||||
- Not using type-safe parameter objects for route model binding
|
||||
|
|
@ -9,7 +9,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.1
|
||||
- php - 8.4.17
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/cashier (CASHIER) - v16
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.1
|
||||
- php - 8.4.17
|
||||
- inertiajs/inertia-laravel (INERTIA) - v2
|
||||
- laravel/cashier (CASHIER) - v16
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
|
|
@ -210,7 +210,7 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||
## Constructors
|
||||
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function \_\_construct(public GitHub $github) { }</code-snippet>
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
|
||||
|
||||
## Type Declarations
|
||||
|
|
@ -422,4 +422,4 @@ Fortify is a headless authentication backend that provides authentication routes
|
|||
- `Features::updateProfileInformation()` to let users update their profile.
|
||||
- `Features::updatePasswords()` to let users change their passwords.
|
||||
- `Features::resetPasswords()` for password reset via email.
|
||||
</laravel-boost-guidelines>
|
||||
</laravel-boost-guidelines>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\BulkUpdateTransactionRequest;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TransactionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return paginated transactions for the authenticated user with optional filters.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = $request->user()
|
||||
->transactions()
|
||||
->with('labels:id,name,color');
|
||||
|
||||
if ($request->query('encrypted') === 'true') {
|
||||
$query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'));
|
||||
} elseif ($request->query('encrypted') === 'false') {
|
||||
$query->whereNull('description_iv')->whereNull('notes_iv');
|
||||
}
|
||||
|
||||
$transactions = $query->simplePaginate(100);
|
||||
|
||||
return response()->json($transactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update transactions (used for decryption migration).
|
||||
*/
|
||||
public function bulkUpdate(BulkUpdateTransactionRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$transactionIds = collect($validated['transactions'])->pluck('id');
|
||||
|
||||
$userTransactionIds = $request->user()
|
||||
->transactions()
|
||||
->whereIn('id', $transactionIds)
|
||||
->pluck('id');
|
||||
|
||||
if ($userTransactionIds->count() !== $transactionIds->count()) {
|
||||
abort(403, 'Some transactions do not belong to the authenticated user.');
|
||||
}
|
||||
|
||||
foreach ($validated['transactions'] as $data) {
|
||||
$updateData = collect($data)->except('id')->toArray();
|
||||
|
||||
Transaction::query()
|
||||
->where('id', $data['id'])
|
||||
->toBase()
|
||||
->update($updateData);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Transactions updated successfully.']);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,8 +44,8 @@ class AccountController extends Controller
|
|||
'name_iv' => null,
|
||||
]);
|
||||
|
||||
// Set user's currency_code from first account if not already set
|
||||
if (is_null($user->currency_code)) {
|
||||
// Set user's currency_code from first account
|
||||
if ($user->accounts()->count() === 1) {
|
||||
$user->update(['currency_code' => $account->currency_code]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,17 @@ class HandleInertiaRequests extends Middleware
|
|||
$isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false;
|
||||
$isDemoQuery = $request->query('demo') === '1';
|
||||
|
||||
// Clean up encryption data if no encrypted accounts or transactions remain
|
||||
if (! $request->is('api/*') && $user?->encryption_salt !== null) {
|
||||
$hasAnyEncryptedData = $user->accounts()->where('encrypted', true)->exists()
|
||||
|| $user->transactions()->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))->exists();
|
||||
|
||||
if (! $hasAnyEncryptedData) {
|
||||
$user->encryptedMessage()->delete();
|
||||
$user->update(['encryption_salt' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...parent::share($request),
|
||||
'flash' => [
|
||||
|
|
@ -101,6 +112,9 @@ class HandleInertiaRequests extends Middleware
|
|||
->get(['id', 'name', 'color']) : [],
|
||||
'hasEncryptedAccounts' => $user?->accounts()->where('encrypted', true)->exists() ?? false,
|
||||
'hasEncryptionSetup' => $user?->encryption_salt !== null,
|
||||
'hasEncryptedTransactions' => $user?->transactions()
|
||||
->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))
|
||||
->exists() ?? false,
|
||||
'locale' => app()->getLocale(),
|
||||
'translations' => $this->getTranslations(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class BulkUpdateTransactionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'transactions' => ['required', 'array', 'min:1', 'max:50'],
|
||||
'transactions.*.id' => ['required', 'uuid'],
|
||||
'transactions.*.description' => ['sometimes', 'string'],
|
||||
'transactions.*.notes' => ['sometimes', 'nullable', 'string'],
|
||||
'transactions.*.description_iv' => ['sometimes', 'nullable', 'string'],
|
||||
'transactions.*.notes_iv' => ['sometimes', 'nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"cursor",
|
||||
"opencode"
|
||||
"opencode",
|
||||
"copilot"
|
||||
],
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ createInertiaApp({
|
|||
const initialIsAuthenticated = Boolean(initialUser);
|
||||
const hasEncryptionSetup =
|
||||
(initialPageProps?.hasEncryptionSetup as boolean) ?? false;
|
||||
const hasEncryptedAccounts =
|
||||
(initialPageProps?.hasEncryptedAccounts as boolean) ?? false;
|
||||
const hasEncryptedTransactions =
|
||||
(initialPageProps?.hasEncryptedTransactions as boolean) ?? false;
|
||||
|
||||
// Initialize translations from server-rendered page data
|
||||
setTranslations(
|
||||
|
|
@ -74,7 +78,12 @@ createInertiaApp({
|
|||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<EncryptionKeyProvider hasEncryptionSetup={hasEncryptionSetup}>
|
||||
<EncryptionKeyProvider
|
||||
hasEncryptionSetup={
|
||||
hasEncryptionSetup &&
|
||||
(hasEncryptedAccounts || hasEncryptedTransactions)
|
||||
}
|
||||
>
|
||||
<PrivacyModeProvider>
|
||||
<SyncProvider
|
||||
initialIsAuthenticated={initialIsAuthenticated}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,15 @@ export function AppSidebarHeader({
|
|||
}: {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
}) {
|
||||
const { hasEncryptionSetup } = usePage<SharedData>().props;
|
||||
const {
|
||||
hasEncryptionSetup,
|
||||
hasEncryptedAccounts,
|
||||
hasEncryptedTransactions,
|
||||
} = usePage<SharedData>().props;
|
||||
|
||||
const showEncryptionButton =
|
||||
hasEncryptionSetup &&
|
||||
(hasEncryptedTransactions || hasEncryptedAccounts);
|
||||
|
||||
return (
|
||||
<header className="pt-safe flex min-h-16 shrink-0 items-center justify-between gap-2 border-b border-sidebar-border/50 px-5 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:min-h-12 sm:px-6 md:px-4">
|
||||
|
|
@ -29,7 +37,7 @@ export function AppSidebarHeader({
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportTransactionsButton />
|
||||
{hasEncryptionSetup && (
|
||||
{showEncryptionButton && (
|
||||
<>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ export function StepMoreAccounts({
|
|||
onAddMore,
|
||||
onFinish,
|
||||
}: StepMoreAccountsProps) {
|
||||
const createdIds = new Set(createdAccounts.map((a) => a.id));
|
||||
const filteredExistingAccounts = existingAccounts.filter(
|
||||
(a) => !createdIds.has(a.id),
|
||||
);
|
||||
|
||||
const description = __(
|
||||
'Would you like to add more accounts or continue to the dashboard?',
|
||||
);
|
||||
|
|
@ -70,7 +75,7 @@ export function StepMoreAccounts({
|
|||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
))}
|
||||
{existingAccounts.map((account) => (
|
||||
{filteredExistingAccounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="flex items-center gap-3 rounded-lg border bg-card p-4"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export function EncryptionKeyProvider({
|
|||
}, [hasEncryptionSetup]);
|
||||
|
||||
async function fetchEncryptedMessage() {
|
||||
if (!hasEncryptionSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get<EncryptedMessageData>(
|
||||
'/api/encryption/message',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface EncryptedTransaction {
|
||||
id: string;
|
||||
description: string;
|
||||
description_iv: string | null;
|
||||
notes: string | null;
|
||||
notes_iv: string | null;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
data: EncryptedTransaction[];
|
||||
next_page_url: string | null;
|
||||
}
|
||||
|
||||
interface BulkUpdateItem {
|
||||
id: string;
|
||||
description?: string;
|
||||
notes?: string | null;
|
||||
description_iv: null;
|
||||
notes_iv: null;
|
||||
}
|
||||
|
||||
export function useDecryptTransactions() {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { hasEncryptedTransactions } = usePage<SharedData>().props;
|
||||
const hasRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isKeySet || !hasEncryptedTransactions || hasRun.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasRun.current = true;
|
||||
|
||||
async function migrateTransactions() {
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await importKey(keyString);
|
||||
|
||||
let url: string | null = '/api/transactions?encrypted=true';
|
||||
|
||||
while (url) {
|
||||
const { data: page } =
|
||||
await axios.get<PaginatedResponse>(url);
|
||||
|
||||
const batch: BulkUpdateItem[] = [];
|
||||
|
||||
for (const transaction of page.data) {
|
||||
try {
|
||||
const item: BulkUpdateItem = {
|
||||
id: transaction.id,
|
||||
description_iv: null,
|
||||
notes_iv: null,
|
||||
};
|
||||
|
||||
if (transaction.description_iv) {
|
||||
item.description = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
}
|
||||
|
||||
if (transaction.notes_iv && transaction.notes) {
|
||||
item.notes = await decrypt(
|
||||
transaction.notes,
|
||||
key,
|
||||
transaction.notes_iv,
|
||||
);
|
||||
}
|
||||
|
||||
batch.push(item);
|
||||
} catch {
|
||||
// Skip transactions that fail to decrypt
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
// Send in chunks of 50
|
||||
for (let i = 0; i < batch.length; i += 50) {
|
||||
const chunk = batch.slice(i, i + 50);
|
||||
await axios.patch('/api/transactions/bulk', {
|
||||
transactions: chunk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
url = page.next_page_url;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// Silent failure — migration will retry next session
|
||||
hasRun.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
migrateTransactions();
|
||||
}, [isKeySet, hasEncryptedTransactions]);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { AppShell } from '@/components/app-shell';
|
|||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { AppSidebarHeader } from '@/components/app-sidebar-header';
|
||||
import { useDecryptAccountNames } from '@/hooks/use-decrypt-account-names';
|
||||
import { useDecryptTransactions } from '@/hooks/use-decrypt-transactions';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export default function AppSidebarLayout({
|
|||
breadcrumbs = [],
|
||||
}: PropsWithChildren<{ breadcrumbs?: BreadcrumbItem[] }>) {
|
||||
useDecryptAccountNames();
|
||||
useDecryptTransactions();
|
||||
|
||||
return (
|
||||
<AppShell variant="sidebar">
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ class TransactionSyncService {
|
|||
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
|
||||
): Promise<Transaction> {
|
||||
const response = await axios.post('/transactions', data);
|
||||
const serverData = response.data.data;
|
||||
const serverData = response.data.data || response.data;
|
||||
|
||||
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
|
@ -100,7 +100,7 @@ class TransactionSyncService {
|
|||
label_ids,
|
||||
});
|
||||
|
||||
const serverData = response.data.data;
|
||||
const serverData = response.data.data || response.data;
|
||||
|
||||
const serverLabelIds = serverData.labels?.map(
|
||||
(l: { id: string }) => l.id,
|
||||
|
|
@ -214,24 +214,24 @@ class TransactionSyncService {
|
|||
});
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
console.warn('No encryption key found for duplicate check');
|
||||
return transactions.map(() => false);
|
||||
}
|
||||
|
||||
const key = await importKey(keyString);
|
||||
const { decrypt } = await import('@/lib/crypto');
|
||||
const key = keyString ? await importKey(keyString) : null;
|
||||
|
||||
const decryptedTransactions = await Promise.all(
|
||||
transactionsInRange.map(async (t) => {
|
||||
try {
|
||||
const decryptedDescription = t.description_iv
|
||||
? await decrypt(
|
||||
t.description,
|
||||
key,
|
||||
t.description_iv,
|
||||
)
|
||||
: t.description;
|
||||
let decryptedDescription: string;
|
||||
if (t.description_iv && key) {
|
||||
const { decrypt } = await import('@/lib/crypto');
|
||||
decryptedDescription = await decrypt(
|
||||
t.description,
|
||||
key,
|
||||
t.description_iv,
|
||||
);
|
||||
} else if (t.description_iv && !key) {
|
||||
return null;
|
||||
} else {
|
||||
decryptedDescription = t.description;
|
||||
}
|
||||
return {
|
||||
transaction_date: normalizeDate(t.transaction_date),
|
||||
amount: parseFloat(t.amount),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export interface SharedData {
|
|||
sidebarOpen: boolean;
|
||||
features: Features;
|
||||
hasEncryptedAccounts: boolean;
|
||||
hasEncryptedTransactions: boolean;
|
||||
hasEncryptionSetup: boolean;
|
||||
locale: string;
|
||||
translations: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AccountController;
|
|||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\ImportDataController;
|
||||
use App\Http\Controllers\Api\TransactionController;
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use App\Http\Controllers\Sync\TransactionSyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -22,6 +23,10 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
Route::get('transactions', [TransactionSyncController::class, 'index']);
|
||||
});
|
||||
|
||||
// Transactions
|
||||
Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
|
||||
|
||||
// Accounts
|
||||
Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index');
|
||||
Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update');
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('onboarding', [OnboardingController::class, 'index'])->name('onboarding');
|
||||
Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('onboarding.complete');
|
||||
});
|
||||
|
||||
// Accessible during onboarding for transaction import
|
||||
Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
|
|
@ -58,7 +61,6 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
|
|||
|
||||
Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index');
|
||||
Route::get('transactions/categorize', [TransactionController::class, 'categorize'])->name('transactions.categorize');
|
||||
Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store');
|
||||
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update');
|
||||
Route::patch('transactions/{transaction}', [TransactionController::class, 'update'])->name('transactions.update');
|
||||
Route::delete('transactions/{transaction}', [TransactionController::class, 'destroy'])->name('transactions.destroy');
|
||||
|
|
|
|||
|
|
@ -88,21 +88,6 @@ it('navigates from welcome to account types', function () {
|
|||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('marks user as onboarded when completing onboarding', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
expect($user->isOnboarded())->toBeFalse();
|
||||
|
||||
$this->actingAs($user)->post('/onboarding/complete');
|
||||
|
||||
$user->refresh();
|
||||
expect($user->isOnboarded())->toBeTrue();
|
||||
expect($user->onboarded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Existing Account Flow Tests
|
||||
// =============================================================================
|
||||
|
|
@ -236,8 +221,10 @@ it('shows add another account form without first account restriction', function
|
|||
// Full End-to-End Flow Test
|
||||
// =============================================================================
|
||||
|
||||
it('completes onboarding flow through account creation', function () {
|
||||
// Create a bank for the account creation step
|
||||
it('completes entire onboarding flow with account creation, transaction import, and ends on subscribe page', function () {
|
||||
// Enable subscriptions so user ends on /subscribe after completing onboarding
|
||||
config(['subscriptions.enabled' => true]);
|
||||
|
||||
Bank::factory()->create(['name' => 'Chase Bank']);
|
||||
|
||||
$user = User::factory()->create([
|
||||
|
|
@ -259,59 +246,93 @@ it('completes onboarding flow through account creation', function () {
|
|||
|
||||
// Step 2: Account Types
|
||||
$page->assertSee('Account Types')
|
||||
->assertSee('Checking')
|
||||
->assertSee('Savings')
|
||||
->assertSee('Credit Card')
|
||||
->click('Create Your First Account')
|
||||
->wait(1);
|
||||
|
||||
// Step 3: Create Account
|
||||
// Step 3: Create Account (checking with EUR currency)
|
||||
$page->assertSee('Create an Account')
|
||||
->assertSee('Your first account must be a')
|
||||
->fill('#display_name', 'My Checking Account')
|
||||
// Select bank from combobox - need to search first
|
||||
->click('Select bank...')
|
||||
->wait(1)
|
||||
->fill('[placeholder="Search bank..."]', 'Chase')
|
||||
->wait(2)
|
||||
->click('Chase Bank')
|
||||
->wait(1)
|
||||
// Select currency - click on the dropdown item (Radix UI creates role="option")
|
||||
->click('Select currency')
|
||||
->wait(1)
|
||||
->click('[role="option"]:has-text("USD")')
|
||||
->click('[role="option"]:has-text("EUR")')
|
||||
->wait(1)
|
||||
->click('Create Account')
|
||||
->wait(5);
|
||||
|
||||
// Step 4: Import Transactions - open the import drawer
|
||||
$page->assertSee('Import Your Transactions')
|
||||
->click('Import Transactions')
|
||||
->wait(3);
|
||||
|
||||
// Step 4: Import Transactions step should appear
|
||||
$page->assertSee('Import Your Transactions')
|
||||
// The drawer auto-selects the only account and moves to Upload File step
|
||||
// Upload the test CSV file
|
||||
$csvPath = __DIR__.'/assets/test-transactions.csv';
|
||||
$page->attach('input[type="file"]', $csvPath)
|
||||
->wait(2)
|
||||
->click('Next')
|
||||
->wait(2);
|
||||
|
||||
// Column Mapping step (auto-detected: Date, Description, Amount)
|
||||
$page->assertSee('Map Columns')
|
||||
->click('Preview Transactions')
|
||||
->wait(3);
|
||||
|
||||
// Preview step - import all 5 transactions from the CSV
|
||||
$page->assertSee('Preview Transactions')
|
||||
->click('Import 5 Transactions')
|
||||
->wait(15);
|
||||
|
||||
// After import completes, drawer closes and transitions to Category Types
|
||||
$page->assertSee('Understanding Categories')
|
||||
->click('Continue')
|
||||
->wait(1);
|
||||
|
||||
// Smart Rules
|
||||
$page->assertSee('Smart Automation Rules')
|
||||
->click('Continue to Import')
|
||||
->wait(1);
|
||||
|
||||
// More Accounts - verify account is listed, then finish
|
||||
$page->assertSee('Great Progress!')
|
||||
->assertSee('My Checking Account')
|
||||
->click('Finish Setup')
|
||||
->wait(1);
|
||||
|
||||
// Complete step
|
||||
$page->assertSee("You're All Set!")
|
||||
->click('Go to Dashboard')
|
||||
->wait(5);
|
||||
|
||||
// Since SUBSCRIPTIONS_ENABLED is true, user should end on /subscribe
|
||||
$page->assertPathIs('/subscribe')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
// Verify account was created
|
||||
expect($user->accounts()->count())->toBe(1);
|
||||
expect($user->accounts()->first()->type->value)->toBe('checking');
|
||||
});
|
||||
|
||||
it('marks user as onboarded when completing via API', function () {
|
||||
$user = User::factory()->create([
|
||||
'onboarded_at' => null,
|
||||
'encryption_salt' => 'test-salt',
|
||||
]);
|
||||
|
||||
$bank = Bank::factory()->create();
|
||||
Account::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'bank_id' => $bank->id,
|
||||
'type' => 'checking',
|
||||
]);
|
||||
|
||||
expect($user->isOnboarded())->toBeFalse();
|
||||
|
||||
// Complete onboarding via POST
|
||||
$this->actingAs($user)->post('/onboarding/complete');
|
||||
|
||||
// === Database Assertions ===
|
||||
$user->refresh();
|
||||
|
||||
// User should be marked as onboarded
|
||||
expect($user->isOnboarded())->toBeTrue();
|
||||
expect($user->onboarded_at)->not->toBeNull();
|
||||
|
||||
// User currency_code should match the first account's currency
|
||||
expect($user->currency_code)->toBe('EUR');
|
||||
|
||||
// Account should exist with correct properties
|
||||
$account = $user->accounts()->first();
|
||||
expect($account)->not->toBeNull();
|
||||
expect($account->type->value)->toBe('checking');
|
||||
expect($account->currency_code)->toBe('EUR');
|
||||
expect($account->name)->toBe('My Checking Account');
|
||||
|
||||
// Transactions should be imported in the correct account
|
||||
$transactions = $user->transactions()->where('account_id', $account->id)->get();
|
||||
expect($transactions)->toHaveCount(5);
|
||||
expect($transactions->pluck('currency_code')->unique()->first())->toBe('EUR');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->onboarded()->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('encrypted transactions endpoint returns only encrypted transactions', function () {
|
||||
$encrypted = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/transactions?encrypted=true');
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data');
|
||||
expect($data)->toHaveCount(1);
|
||||
expect($data[0]['id'])->toBe($encrypted->id);
|
||||
});
|
||||
|
||||
test('plaintext filter returns only plaintext transactions', function () {
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
$plaintext = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/transactions?encrypted=false');
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data');
|
||||
expect($data)->toHaveCount(1);
|
||||
expect($data[0]['id'])->toBe($plaintext->id);
|
||||
});
|
||||
|
||||
test('encrypted transactions endpoint paginates correctly', function () {
|
||||
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->count(150)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/transactions?encrypted=true');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data'))->toHaveCount(100);
|
||||
expect($response->json('next_page_url'))->not->toBeNull();
|
||||
|
||||
$response2 = $this->getJson('/api/transactions?encrypted=true&page=2');
|
||||
expect($response2->json('data'))->toHaveCount(50);
|
||||
expect($response2->json('next_page_url'))->toBeNull();
|
||||
});
|
||||
|
||||
test('encrypted transactions endpoint is scoped to authenticated user', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
$myTransaction = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/transactions?encrypted=true');
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json('data');
|
||||
expect($data)->toHaveCount(1);
|
||||
expect($data[0]['id'])->toBe($myTransaction->id);
|
||||
});
|
||||
|
||||
test('bulk update clears IVs and sets plaintext values', function () {
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description' => 'encrypted-desc',
|
||||
'description_iv' => 'some-iv',
|
||||
'notes' => 'encrypted-notes',
|
||||
'notes_iv' => 'some-notes-iv',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => [
|
||||
[
|
||||
'id' => $transaction->id,
|
||||
'description' => 'Decrypted description',
|
||||
'notes' => 'Decrypted notes',
|
||||
'description_iv' => null,
|
||||
'notes_iv' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
expect($transaction->description)->toBe('Decrypted description');
|
||||
expect($transaction->notes)->toBe('Decrypted notes');
|
||||
expect($transaction->description_iv)->toBeNull();
|
||||
expect($transaction->notes_iv)->toBeNull();
|
||||
});
|
||||
|
||||
test('bulk update rejects other user transactions', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $otherUser->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
|
||||
$response = $this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => [
|
||||
[
|
||||
'id' => $transaction->id,
|
||||
'description' => 'Stolen data',
|
||||
'description_iv' => null,
|
||||
'notes_iv' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
test('bulk update validates max batch size', function () {
|
||||
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$transactions = Transaction::factory()->count(51)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
|
||||
$payload = $transactions->map(fn ($t) => [
|
||||
'id' => $t->id,
|
||||
'description' => 'decrypted',
|
||||
'description_iv' => null,
|
||||
'notes_iv' => null,
|
||||
])->toArray();
|
||||
|
||||
$response = $this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => $payload,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('transactions');
|
||||
});
|
||||
|
||||
test('bulk update validates required fields', function () {
|
||||
$response = $this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => [
|
||||
['description' => 'missing id'],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('transactions.0.id');
|
||||
});
|
||||
|
||||
test('bulk update handles nullable notes correctly', function () {
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description' => 'encrypted-desc',
|
||||
'description_iv' => 'some-iv',
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => [
|
||||
[
|
||||
'id' => $transaction->id,
|
||||
'description' => 'Decrypted description',
|
||||
'notes' => null,
|
||||
'description_iv' => null,
|
||||
'notes_iv' => null,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
expect($transaction->description)->toBe('Decrypted description');
|
||||
expect($transaction->notes)->toBeNull();
|
||||
expect($transaction->description_iv)->toBeNull();
|
||||
expect($transaction->notes_iv)->toBeNull();
|
||||
});
|
||||
|
||||
test('guests cannot access transactions API', function () {
|
||||
auth()->logout();
|
||||
|
||||
$this->getJson('/api/transactions')->assertUnauthorized();
|
||||
$this->patchJson('/api/transactions/bulk', ['transactions' => []])->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('bulk update does not fire model events', function () {
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'description_iv' => 'some-iv',
|
||||
]);
|
||||
|
||||
$originalUpdatedAt = $transaction->updated_at;
|
||||
|
||||
$this->patchJson('/api/transactions/bulk', [
|
||||
'transactions' => [
|
||||
[
|
||||
'id' => $transaction->id,
|
||||
'description' => 'Decrypted',
|
||||
'description_iv' => null,
|
||||
'notes_iv' => null,
|
||||
],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
$transaction->refresh();
|
||||
expect($transaction->updated_at->toDateTimeString())->toBe($originalUpdatedAt->toDateTimeString());
|
||||
});
|
||||
Loading…
Reference in New Issue