diff --git a/app/Http/Controllers/ActorPivotController.php b/app/Http/Controllers/ActorPivotController.php index 4b23d8e1..23610c9f 100644 --- a/app/Http/Controllers/ActorPivotController.php +++ b/app/Http/Controllers/ActorPivotController.php @@ -4,6 +4,7 @@ use App\Models\Actor; use App\Models\ActorPivot; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -58,7 +59,14 @@ public function store(Request $request) 'date' => Now(), ]); - return ActorPivot::create($request->except(['_token', '_method'])); + try { + return ActorPivot::create($request->except(['_token', '_method'])); + } catch (UniqueConstraintViolationException $exception) { + return ActorPivot::where('matter_id', $request->matter_id) + ->where('role', $request->role) + ->where('actor_id', $request->actor_id) + ->firstOrFail(); + } } /** diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index e45b5fd8..f2a51962 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,29 +3,20 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; -use Illuminate\Foundation\Auth\AuthenticatesUsers; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; /** * Handles user authentication and login. * - * Uses Laravel's AuthenticatesUsers trait to provide standard login functionality. - * Configured to use the 'login' field instead of 'email' for authentication. + * This implementation intentionally avoids framework-internal auth traits so it + * remains stable across Laravel versions. */ class LoginController extends Controller { - /* - |-------------------------------------------------------------------------- - | Login Controller - |-------------------------------------------------------------------------- - | - | This controller handles authenticating users for the application and - | redirecting them to your home screen. The controller uses a trait - | to conveniently provide its functionality to your applications. - | - */ - - use AuthenticatesUsers; - /** * Where to redirect users after login. * @@ -45,9 +36,91 @@ public function __construct() } /** - * Get the login username field. + * Display the login form. + * + * @return \Illuminate\View\View + */ + public function showLoginForm() + { + return view('auth.login'); + } + + /** + * Handle an authentication attempt. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + * + * @throws \Illuminate\Validation\ValidationException + */ + public function login(Request $request) + { + $this->validateLogin($request); + $this->ensureIsNotRateLimited($request); + + if (! Auth::attempt($this->credentials($request), $request->has('remember'))) { + RateLimiter::hit($this->throttleKey($request)); + + return $this->sendFailedLoginResponse($request); + } + + RateLimiter::clear($this->throttleKey($request)); + $request->session()->regenerate(); + + if (method_exists($this, 'authenticated')) { + $response = $this->authenticated($request, Auth::user()); + + if ($response) { + return $response; + } + } + + return redirect()->intended($this->redirectPath()); + } + + /** + * Log the user out of the application. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function logout(Request $request) + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect('/'); + } + + /** + * Validate the user login request. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function validateLogin(Request $request) + { + $request->validate([ + $this->username() => ['required', 'string'], + 'password' => ['required', 'string'], + ]); + } + + /** + * Get the needed authorization credentials from the request. * - * Uses the 'login' column instead of Laravel's default 'email' field. + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function credentials(Request $request) + { + return $request->only($this->username(), 'password'); + } + + /** + * Get the login username field. * * @return string */ @@ -55,4 +128,64 @@ public function username() { return 'login'; } + + /** + * Get the post-login redirect path. + * + * @return string + */ + protected function redirectPath() + { + return property_exists($this, 'redirectTo') ? $this->redirectTo : '/home'; + } + + /** + * Get the failed login response instance. + * + * @param \Illuminate\Http\Request $request + * @return never + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function sendFailedLoginResponse(Request $request) + { + throw ValidationException::withMessages([ + $this->username() => [trans('auth.failed')], + ]); + } + + /** + * Ensure the login request has not exceeded the allowed attempt count. + * + * @param \Illuminate\Http\Request $request + * @return void + * + * @throws \Illuminate\Validation\ValidationException + */ + protected function ensureIsNotRateLimited(Request $request) + { + if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey($request)); + + throw ValidationException::withMessages([ + $this->username() => [trans('auth.throttle', [ + 'seconds' => $seconds, + 'minutes' => ceil($seconds / 60), + ])], + ])->status(429); + } + + /** + * Get the rate limiting key for the request. + * + * @param \Illuminate\Http\Request $request + * @return string + */ + protected function throttleKey(Request $request) + { + return Str::lower($request->input($this->username(), '')) . '|' . $request->ip(); + } } diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 8191d678..fce4fe57 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -8,8 +8,8 @@ use App\Models\ActorPivot; use App\Models\Matter; use App\Services\DocumentMergeService; +use App\Services\FamilyDataService; use App\Services\MatterExportService; -use App\Services\OPSService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -27,24 +27,24 @@ class MatterController extends Controller { protected DocumentMergeService $documentMergeService; protected MatterExportService $matterExportService; - protected OPSService $opsService; + protected FamilyDataService $familyDataService; /** * Initialize the controller with required services. * * @param DocumentMergeService $documentMergeService Service for merging matter data into documents. * @param MatterExportService $matterExportService Service for exporting matters to CSV. - * @param OPSService $opsService Service for interacting with EPO OPS API. + * @param FamilyDataService $familyDataService Service for retrieving family data from OPS/USPTO. */ public function __construct( DocumentMergeService $documentMergeService, MatterExportService $matterExportService, - OPSService $opsService + FamilyDataService $familyDataService ) { $this->documentMergeService = $documentMergeService; $this->matterExportService = $matterExportService; - $this->opsService = $opsService; + $this->familyDataService = $familyDataService; } /** @@ -378,7 +378,7 @@ public function storeFamily(Request $request) 'client_id' => 'required', ]); - $apps = collect($this->opsService->getFamilyMembers($request->docnum)); + $apps = collect($this->familyDataService->getFamilyMembers($request->docnum)); if ($apps->has('errors') || $apps->has('exception')) { return response()->json($apps); } @@ -463,7 +463,7 @@ public function storeFamily(Request $request) $new_matter->classifiersNative()->create(['type_code' => 'TIT', 'value' => $app['title']]); } $new_matter->actorPivot()->create(['actor_id' => $request->client_id, 'role' => 'CLI', 'shared' => 1]); - if (array_key_exists('applicants', $app)) { + if (array_key_exists('applicants', $app) && !empty($app['applicants'])) { if (strtolower($app['applicants'][0]) == strtolower(Actor::find($request->client_id)->name)) { $new_matter->actorPivot()->create( [ @@ -508,7 +508,7 @@ public function storeFamily(Request $request) } $new_matter->notes = 'Applicants: ' . collect($app['applicants'])->implode('; '); } - if (array_key_exists('inventors', $app)) { + if (array_key_exists('inventors', $app) && !empty($app['inventors'])) { foreach ($app['inventors'] as $inventor) { // Search for phonetically equivalent in the actor table, and take first if (substr($inventor, -1) == ',') { @@ -815,7 +815,7 @@ public function mergeFile(Matter $matter, MergeFileRequest $request) */ public function getOPSfamily(string $docnum) { - return $this->opsService->getFamilyMembers($docnum); + return $this->familyDataService->getFamilyMembers($docnum); } /** diff --git a/app/Services/FamilyDataService.php b/app/Services/FamilyDataService.php new file mode 100644 index 00000000..5f76c19c --- /dev/null +++ b/app/Services/FamilyDataService.php @@ -0,0 +1,80 @@ +opsService->getFamilyMembers($docnum); + if (array_key_exists('errors', $apps) || array_key_exists('exception', $apps)) { + // If the requested number looks US, return a synthetic single-member family + // from USPTO ODP when possible. + if ($this->isUSDocument($docnum)) { + $member = $this->buildUSMemberFromODP($docnum); + if (!empty($member)) { + return [$member]; + } + } + + return $apps; + } + + return $this->usptoService->enrichFamilyMembers($apps); + } + + private function isUSDocument(string $docnum): bool + { + return str_starts_with(strtoupper(trim($docnum)), 'US'); + } + + private function buildUSMemberFromODP(string $docnum): array + { + $number = preg_replace('/\D/', '', $docnum); + if (!$number) { + return []; + } + + $odData = $this->usptoService->getApplicationData($number); + if (empty($odData)) { + return []; + } + + return [ + 'id' => 'US' . $number, + 'app' => [ + 'country' => 'US', + 'number' => ltrim($number, '0'), + 'kind' => 'A', + 'date' => null, + ], + 'pri' => [], + 'pct' => null, + 'div' => null, + 'cnt' => null, + 'title' => $odData['title'] ?? null, + 'applicants' => $odData['applicants'] ?? [], + 'inventors' => $odData['inventors'] ?? [], + 'procedure' => $odData['procedure'] ?? [], + ]; + } +} + diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..922e23cd 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,30 @@ public function getFamilyMembers(string $docnum): array ->last()['$']; // Each inventor is under [i]['inventor-name']['name']['$'] both in "epodoc" and "original" format - $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor']) - ->where('@data-format', 'original'); - $apps[0]['inventors'] = $inventors->values()->pluck('inventor-name.name.$'); + $inventorData = $member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []; + $inventors = collect(array_is_list($inventorData) ? $inventorData : [$inventorData]) + ->where('@data-format', 'original') + ->values() + ->pluck('inventor-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($inventors)) { + $apps[0]['inventors'] = $inventors; + } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant']) - ->where('@data-format', 'original'); - $apps[0]['applicants'] = $applicants->values()->pluck('applicant-name.name.$'); + $applicantData = $member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []; + $applicants = collect(array_is_list($applicantData) ? $applicantData : [$applicantData]) + ->where('@data-format', 'original') + ->values() + ->pluck('applicant-name.name.$') + ->filter() + ->values() + ->all(); + if (!empty($applicants)) { + $apps[0]['applicants'] = $applicants; + } $procedureSteps = $this->getProceduralSteps($app_number); if (!empty($procedureSteps)) { diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php new file mode 100644 index 00000000..f248bb5e --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,284 @@ + $app) { + if (data_get($app, 'app.country') !== 'US') { + continue; + } + + $number = data_get($app, 'app.number'); + if (!$number) { + continue; + } + + $odData = $this->getApplicationData((string) $number); + if (empty($odData)) { + continue; + } + + if (empty($apps[0]['title']) && !empty($odData['title'])) { + $apps[0]['title'] = $odData['title']; + } + if (empty($apps[0]['applicants']) && !empty($odData['applicants'])) { + $apps[0]['applicants'] = $odData['applicants']; + } + if (empty($apps[0]['inventors']) && !empty($odData['inventors'])) { + $apps[0]['inventors'] = $odData['inventors']; + } + if (empty($apps[$index]['procedure']) && !empty($odData['procedure'])) { + $apps[$index]['procedure'] = $odData['procedure']; + } + } + + return $apps; + } + + /** + * Fetch a single US application using configured USPTO ODP endpoints. + * + * @param string $applicationNumber + * @return array + */ + public function getApplicationData(string $applicationNumber): array + { + if (!config('services.uspto.enabled')) { + return []; + } + + $normalizedNumber = preg_replace('/\D/', '', $applicationNumber); + if (!$normalizedNumber) { + return []; + } + + $apiKey = config('services.uspto.api_key'); + $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; + + // Preferred path: direct application endpoint (built-in default + optional override). + $templates = array_filter(array_unique([ + config('services.uspto.application_endpoint'), + '/api/v1/patent/applications/{applicationNumber}', + ])); + + foreach ($templates as $template) { + $url = $this->resolveEndpointUrl( + str_replace('{applicationNumber}', $normalizedNumber, $template) + ); + + if (empty($url)) { + continue; + } + + $response = $this->getJson($url, $headers); + if ($response?->successful()) { + $normalized = $this->normalizeRecord($response->json()); + if (!empty(array_filter($normalized))) { + return $normalized; + } + } + } + + // Fallback path: search endpoint (built-in default + optional override). + $searchEndpoint = config('services.uspto.search_endpoint'); + $searchUrl = $this->resolveEndpointUrl( + $searchEndpoint ?: '/api/v1/patent/applications/search' + ); + if (empty($searchUrl)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $queryStringPayload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = $this->getJson($searchUrl, $headers, $queryStringPayload); + if ($response?->successful()) { + $normalized = $this->normalizeRecord($response->json()); + if (!empty(array_filter($normalized))) { + return $normalized; + } + } + + // Secondary fallback: POST JSON search payload. + $jsonSearchPayload = [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['term' => [$queryField => $normalizedNumber]], + ], + ], + ], + 'size' => 1, + ]; + + $response = $this->postJson($searchUrl, $headers, $jsonSearchPayload); + if ($response?->successful()) { + return $this->normalizeRecord($response->json()); + } + + return []; + } + + /** + * Issue a USPTO GET request, allowing callers to continue through fallbacks on timeouts. + * + * @param array $headers + * @param array $query + */ + private function getJson(string $url, array $headers, array $query = []): ?\Illuminate\Http\Client\Response + { + try { + return Http::timeout(10)->withHeaders($headers)->acceptJson()->get($url, $query); + } catch (ConnectionException) { + return null; + } + } + + /** + * Issue a USPTO POST request, allowing callers to return an empty enrichment on timeouts. + * + * @param array $headers + * @param array $payload + */ + private function postJson(string $url, array $headers, array $payload): ?\Illuminate\Http\Client\Response + { + try { + return Http::timeout(10)->withHeaders($headers)->acceptJson()->post($url, $payload); + } catch (ConnectionException) { + return null; + } + } + + /** + * Normalize a USPTO payload to phpIP expected fields. + * + * @param mixed $payload + * @return array + */ + private function normalizeRecord($payload): array + { + $record = Arr::first(data_get($payload, 'hits.hits', []), null, []); + if (array_key_exists('_source', $record)) { + $record = $record['_source']; + } elseif (array_key_exists('record', $payload)) { + $record = $payload['record']; + } elseif (array_key_exists('results', $payload)) { + $record = Arr::first($payload['results'], []); + } elseif (array_key_exists('items', $payload)) { + $record = Arr::first($payload['items'], []); + } elseif (array_key_exists('applications', $payload)) { + $record = Arr::first($payload['applications'], []); + } elseif (array_key_exists('data', $payload) && is_array($payload['data'])) { + $data = $payload['data']; + if (array_is_list($data)) { + $record = Arr::first($data, []); + } else { + $record = $data; + } + } elseif (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + if (array_key_exists('applicationMetaData', $record) && is_array($record['applicationMetaData'])) { + $record = array_merge($record['applicationMetaData'], $record); + } + + $applicants = collect( + data_get( + $record, + 'applicants', + data_get($record, 'applicantName', data_get($record, 'parties.applicants', [])) + ) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get( + $value, + 'name', + data_get($value, 'applicantName', data_get($value, 'partyName')) + ); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get( + $record, + 'inventors', + data_get($record, 'inventorName', data_get($record, 'parties.inventors', [])) + ) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get( + $value, + 'name', + data_get($value, 'inventorName', data_get($value, 'partyName')) + ); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get( + $record, + 'inventionTitle', + data_get($record, 'title', data_get($record, 'applicationTitleText')) + ), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get( + $record, + 'events', + data_get($record, 'legalEvents', data_get($record, 'transactions', [])) + ), + ]; + } + + private function resolveEndpointUrl(?string $endpoint): ?string + { + if (empty($endpoint)) { + return null; + } + + if (str_starts_with($endpoint, 'http://') || str_starts_with($endpoint, 'https://')) { + return $endpoint; + } + + $baseUrl = rtrim((string) config('services.uspto.base_url', 'https://api.uspto.gov'), '/'); + $path = '/' . ltrim($endpoint, '/'); + + return $baseUrl . $path; + } +} diff --git a/composer.json b/composer.json index 861908af..8ace9ef2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "require-dev": { "laravel/pint": "^1.17", "laravel/tinker": "^3.0", - "laravel/ui": "^4.2", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.1", "phpunit/phpunit": "^12.0" diff --git a/config/database.php b/config/database.php index 673eabb6..cec243b8 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,7 @@ 'strict' => false, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + (class_exists(\Pdo\Mysql::class) ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/config/services.php b/config/services.php index 715f8284..c822b312 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,18 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Base USPTO ODP API URL; endpoint defaults below are resolved against this value. + 'base_url' => env('USPTO_ODP_BASE_URL', 'https://api.uspto.gov'), + // Optional override for direct application endpoint template. + // Leave empty to use the built-in default. + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT', '/api/v1/patent/applications/{applicationNumber}'), + // Optional override for search endpoint. + // Leave empty to use the built-in default. + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT', '/api/v1/patent/applications/search'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; diff --git a/doc/README.md b/doc/README.md index d573754a..f115e405 100644 --- a/doc/README.md +++ b/doc/README.md @@ -169,3 +169,9 @@ The software is under development, so many changes can occur. To stay up to date * `composer install` The database structure may be updated too, so you need to apply the new migration scripts in `database/migrations`. Just run `php artisan migrate` in the root folder, which will apply the latest scripts. + +## 3.4 USPTO ODP optional setup (US family enrichment/fallback) + +If you want phpIP to enrich/fallback US family data using USPTO ODP, configure the related `.env` variables and cache clear steps described in: + +* `docs/USPTO_ODP.md` diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md new file mode 100644 index 00000000..f8cc398a --- /dev/null +++ b/docs/USPTO_ODP.md @@ -0,0 +1,93 @@ +# USPTO ODP integration guide + +This guide explains how phpIP uses USPTO Open Data Portal (ODP) data together with EPO OPS for patent family import. + +> **Beta status:** USPTO ODP support is still experimental. Enabling it should not cause problems for the existing OPS import path, but USPTO ODP enrichment/fallback may not retrieve or normalize records correctly in all cases yet. + +## 1) How provider switching works + +phpIP now uses a provider orchestrator (`FamilyDataService`) for family retrieval: + +1. Try EPO OPS first (existing behavior). +2. If OPS succeeds, optionally enrich US members with USPTO ODP data (title/applicants/inventors/procedure). +3. If OPS fails and the input document number looks US, try building a synthetic single-member US record from USPTO ODP. + +This means **you still use the same UI action**: + +`Matters -> Create family from OPS` + +No new UI menu is required. + +## 2) How to use it + +1. Log in to phpIP. +2. Open `Matters -> Create family from OPS`. +3. Enter an EP, WO, or other OPS-supported publication/application number as before. +4. For a family containing US applications, phpIP keeps the OPS family data and uses USPTO ODP to fill missing US title, applicant, inventor, or procedure details when ODP is enabled. +5. For a US application where OPS cannot provide the family data, phpIP attempts to use USPTO ODP as a fallback source for a single US matter. + +The feature is designed as an import enhancement, not a separate workflow. If USPTO ODP is disabled or unavailable, the existing OPS import flow continues to be used. While the USPTO ODP integration is in beta, failed or incomplete ODP retrieval should simply mean less enrichment data, not a broken OPS import. + +## 3) Configuration + +Set the following variables in `.env`: + +```dotenv +# Enable/disable USPTO enrichment/fallback +USPTO_ODP_ENABLED=true + +# Optional API key (if your ODP dataset requires one) +USPTO_ODP_API_KEY= + +# Optional override: USPTO API base URL (default already works for ODP) +# USPTO_ODP_BASE_URL=https://api.uspto.gov + +# Optional overrides (advanced only) +# USPTO_ODP_APPLICATION_ENDPOINT=/api/v1/patent/applications/{applicationNumber} +# USPTO_ODP_SEARCH_ENDPOINT=/api/v1/patent/applications/search +USPTO_ODP_SEARCH_FIELD=applicationNumberText +``` + +Then clear Laravel config cache: + +```bash +php artisan optimize:clear +``` + +## 4) Required existing OPS settings + +USPTO support does **not** replace OPS setup. Keep OPS credentials configured: + +```dotenv +OPS_APP_KEY=... +OPS_SECRET=... +``` + +The orchestrator depends on OPS as the primary family source. + +## 5) Validation checklist + +1. Log in to phpIP. +2. Open `Matters -> Create family from OPS`. +3. Enter a document number from a family containing US members. +4. Run import. +5. Confirm that import no longer fails when OPS has sparse US party data. + +If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. + +## 6) Troubleshooting (USPTO ODP only) + +### OPS import works but US enrichment does not + +Check: + +- `USPTO_ODP_ENABLED=true` +- valid API key (if required by your account/product) +- API key requirements for your ODP dataset +- network egress to the endpoint host from your phpIP server + +## 7) Security notes + +- Keep API keys in `.env`, never in source files. +- Restrict outbound network access from the server to approved API hosts only. +- Consider request logging/redaction policy for external API errors. diff --git a/readme.md b/readme.md index bd28da78..50f6208a 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,18 @@ Head for the [Wiki](https://github.com/jjdejong/phpip/wiki) for further informat # New features +## 2026-04-23 USPTO ODP fallback/enrichment for US family members + +Family import now uses a dedicated provider orchestration service. EPO OPS remains the primary family source, while USPTO ODP can enrich US family members with missing title, applicant, inventor, or procedure data. If OPS cannot return a family for a US application, phpIP can fall back to USPTO ODP to prepare a single US matter from the available ODP record. + +The existing UI entry point remains unchanged: `Matters -> Create family from OPS`. + +The import is also more tolerant of sparse OPS data: missing applicants/inventors and single-party OPS response objects no longer stop family creation. + +USPTO ODP support should be considered beta. When enabled, it is designed not to disturb the existing OPS import flow, but USPTO ODP data may not always be retrieved or normalized correctly yet. + +Setup and usage instructions are documented in the [USPTO ODP integration guide](docs/USPTO_ODP.md). + ## 2025-08-04 Countries Implemented translations for country names. diff --git a/routes/web.php b/routes/web.php index 1e43ae5d..bf0ccd8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,12 +34,38 @@ use App\Http\Controllers\AutocompleteController; use App\Http\Controllers\MatterSearchController; use App\Http\Controllers\ClassifierController; +use App\Http\Controllers\Auth\LoginController; +use App\Http\Controllers\Auth\ForgotPasswordController; +use App\Http\Controllers\Auth\ResetPasswordController; +use App\Http\Controllers\Auth\ConfirmPasswordController; +use App\Http\Controllers\Auth\VerificationController; Route::get('/', function () { return view('welcome'); }); -Auth::routes(['register' => false]); +// Auth routes defined explicitly to avoid requiring the laravel/ui route macro. +Route::middleware('guest')->group(function () { + Route::get('login', [LoginController::class, 'showLoginForm'])->name('login'); + Route::post('login', [LoginController::class, 'login']); + + Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request'); + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); + Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); + Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.update'); +}); + +Route::post('logout', [LoginController::class, 'logout'])->name('logout'); + +Route::middleware('auth')->group(function () { + Route::get('password/confirm', [ConfirmPasswordController::class, 'showConfirmForm'])->name('password.confirm'); + Route::post('password/confirm', [ConfirmPasswordController::class, 'confirm']); + Route::get('email/verify', [VerificationController::class, 'show'])->name('verification.notice'); + Route::get('email/verify/{id}/{hash}', [VerificationController::class, 'verify']) + ->middleware(['signed', 'throttle:6,1']) + ->name('verification.verify'); + Route::post('email/resend', [VerificationController::class, 'resend'])->name('verification.resend'); +}); Route::get('/home', [HomeController::class, 'index'])->name('home'); diff --git a/tests/Unit/USPTOServiceTest.php b/tests/Unit/USPTOServiceTest.php new file mode 100644 index 00000000..9c5ddd9d --- /dev/null +++ b/tests/Unit/USPTOServiceTest.php @@ -0,0 +1,57 @@ + true, + 'services.uspto.api_key' => null, + 'services.uspto.application_endpoint' => null, + 'services.uspto.base_url' => 'https://api.uspto.test', + ]); + + Http::fake(function () { + throw new ConnectionException('Connection timed out.'); + }); + + $this->assertSame([], app(USPTOService::class)->getApplicationData('17/123456')); + } + + public function testGetApplicationDataContinuesToFallbackEndpointsAfterTimeout(): void + { + config([ + 'services.uspto.enabled' => true, + 'services.uspto.api_key' => null, + 'services.uspto.application_endpoint' => '/api/v1/custom/{applicationNumber}', + 'services.uspto.base_url' => 'https://api.uspto.test', + ]); + + Http::fake([ + 'https://api.uspto.test/api/v1/custom/17123456' => function () { + throw new ConnectionException('Connection timed out.'); + }, + 'https://api.uspto.test/api/v1/patent/applications/17123456' => Http::response([ + 'applicationMetaData' => [ + 'inventionTitle' => 'Fallback title', + 'applicantName' => ['Fallback applicant'], + 'inventorName' => ['Fallback inventor'], + ], + ]), + ]); + + $this->assertSame([ + 'title' => 'Fallback title', + 'applicants' => ['Fallback applicant'], + 'inventors' => ['Fallback inventor'], + 'procedure' => [], + ], app(USPTOService::class)->getApplicationData('17/123456')); + } +}