From afc7aa04d9645712ff5539f7ec2a68548533ca43 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 13:27:58 -0500 Subject: [PATCH 01/14] Fix OPS family import when applicants/inventors are missing --- app/Http/Controllers/MatterController.php | 4 ++-- app/Services/OPSService.php | 26 +++++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..ad490b29 100644 --- a/app/Http/Controllers/MatterController.php +++ b/app/Http/Controllers/MatterController.php @@ -444,7 +444,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( [ @@ -489,7 +489,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) == ',') { diff --git a/app/Services/OPSService.php b/app/Services/OPSService.php index e688d4ad..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ 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.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->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.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->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)) { From cbab030428320d9c6dfe846a02bbef08878ef207 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 13:58:32 -0500 Subject: [PATCH 02/14] Fix MySQL SSL option constant for PHP compatibility --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ config/database.php | 2 +- config/services.php | 10 ++ 6 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 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; @@ -26,24 +26,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; } /** @@ -359,7 +359,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); } @@ -444,7 +444,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( [ @@ -489,7 +489,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) == ',') { @@ -796,7 +796,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..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ 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.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->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.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->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..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $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: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * 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 (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + 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..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; From 5da49a0fb654242daad651b7180b520d35cedd45 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 14:48:50 -0500 Subject: [PATCH 03/14] Replace Auth::routes macro with explicit auth routes --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 10 ++ routes/web.php | 28 +++- 8 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 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; @@ -26,24 +26,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; } /** @@ -359,7 +359,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); } @@ -444,7 +444,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( [ @@ -489,7 +489,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) == ',') { @@ -796,7 +796,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..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ 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.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->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.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->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..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $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: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * 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 (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + 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..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + 'search_field' => env('USPTO_ODP_SEARCH_FIELD', 'applicationNumberText'), + ], + ]; diff --git a/routes/web.php b/routes/web.php index 40612642..214a0eee 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'); From e0db37e6f7abf1037d901005e4ae4ecf7809c35c Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 14:56:04 -0500 Subject: [PATCH 04/14] Add USPTO ODP setup and usage manual --- app/Http/Controllers/MatterController.php | 18 +-- app/Services/FamilyDataService.php | 80 +++++++++++ app/Services/OPSService.php | 26 +++- app/Services/USPTOService.php | 162 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 10 ++ doc/README.md | 6 + docs/USPTO_ODP.md | 100 +++++++++++++ readme.md | 8 ++ routes/web.php | 28 +++- 11 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php create mode 100644 docs/USPTO_ODP.md diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 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; @@ -26,24 +26,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; } /** @@ -359,7 +359,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); } @@ -444,7 +444,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( [ @@ -489,7 +489,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) == ',') { @@ -796,7 +796,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..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ 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.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->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.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->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..42683dcb --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,162 @@ + $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: a direct endpoint template containing {applicationNumber}. + $template = config('services.uspto.application_endpoint'); + if (!empty($template)) { + $url = str_replace('{applicationNumber}', $normalizedNumber, $template); + $response = Http::withHeaders($headers)->get($url); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } + + // Fallback path: generic search endpoint. + $searchEndpoint = config('services.uspto.search_endpoint'); + if (empty($searchEndpoint)) { + return []; + } + + $queryField = config('services.uspto.search_field', 'applicationNumberText'); + $payload = [ + 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), + 'size' => 1, + ]; + + $response = Http::withHeaders($headers)->get($searchEndpoint, $payload); + if (!$response->successful()) { + return []; + } + + return $this->normalizeRecord($response->json()); + } + + /** + * 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 (!is_array($record) || empty($record)) { + $record = is_array($payload) ? $payload : []; + } + + $applicants = collect( + data_get($record, 'applicants', data_get($record, 'applicantName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'applicantName')); + }) + ->filter() + ->values() + ->all(); + + $inventors = collect( + data_get($record, 'inventors', data_get($record, 'inventorName', [])) + ) + ->map(function ($value) { + if (is_string($value)) { + return $value; + } + + return data_get($value, 'name', data_get($value, 'inventorName')); + }) + ->filter() + ->values() + ->all(); + + return [ + 'title' => data_get($record, 'inventionTitle', data_get($record, 'title')), + 'applicants' => $applicants, + 'inventors' => $inventors, + 'procedure' => data_get($record, 'events', data_get($record, 'legalEvents', [])), + ]; + } +} + 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..af6a38b3 100644 --- a/config/services.php +++ b/config/services.php @@ -50,4 +50,14 @@ function($carry, $item) { ), ], + 'uspto' => [ + 'enabled' => env('USPTO_ODP_ENABLED', false), + 'api_key' => env('USPTO_ODP_API_KEY'), + // Preferred endpoint template, e.g. https://api.uspto.gov/.../{applicationNumber} + 'application_endpoint' => env('USPTO_ODP_APPLICATION_ENDPOINT'), + // Optional generic search endpoint fallback, e.g. https://api.uspto.gov/api/v1/.../search + 'search_endpoint' => env('USPTO_ODP_SEARCH_ENDPOINT'), + '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..638a7e59 --- /dev/null +++ b/docs/USPTO_ODP.md @@ -0,0 +1,100 @@ +# USPTO ODP integration guide + +This guide explains how phpIP uses USPTO Open Data Portal (ODP) data together with EPO OPS for patent family import. + +## 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) 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= + +# Option A (preferred): direct endpoint template with placeholder +# Example shape: https:////{applicationNumber} +USPTO_ODP_APPLICATION_ENDPOINT= + +# Option B (fallback): search endpoint +# Example shape: https:////search +USPTO_ODP_SEARCH_ENDPOINT= +USPTO_ODP_SEARCH_FIELD=applicationNumberText +``` + +> Use endpoint URLs exactly as provided by your USPTO ODP API product page. +> phpIP does not hardcode a specific product URL because ODP products can differ. + +Then clear Laravel config cache: + +```bash +php artisan optimize:clear +``` + +## 3) 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. + +## 4) 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. + +## 5) Troubleshooting + +### A) `Auth::routes()` / laravel-ui errors + +The app now defines auth routes explicitly and does not rely on the `Auth::routes()` macro. +If you still see old behavior, clear caches and redeploy updated code: + +```bash +php artisan optimize:clear +composer install --no-dev --optimize-autoloader +``` + +### B) `Class "Pdo\\Mysql" not found` + +Ensure `pdo` + `pdo_mysql` are installed on your PHP runtime. phpIP includes a compatibility fallback for SSL CA constant lookup, but DB drivers are still required. + +### C) OPS import works but US enrichment does not + +Check: + +- `USPTO_ODP_ENABLED=true` +- valid endpoint URL(s) +- API key requirements for your ODP dataset +- network egress to the endpoint host from your phpIP server + +## 6) 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..40233545 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,14 @@ 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 OPS as primary source, with optional USPTO ODP enrichment/fallback for US applications. + +The existing UI entry point remains unchanged: `Matters -> Create family from OPS`. + +Setup instructions are documented in [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 40612642..214a0eee 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'); From e34c4a9441cd053e2f684273704ab9d08858123a Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 15:35:23 -0500 Subject: [PATCH 05/14] Clarify ODP manual scope and remove PDO troubleshooting --- docs/USPTO_ODP.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md index 638a7e59..6e7dfd11 100644 --- a/docs/USPTO_ODP.md +++ b/docs/USPTO_ODP.md @@ -79,11 +79,7 @@ php artisan optimize:clear composer install --no-dev --optimize-autoloader ``` -### B) `Class "Pdo\\Mysql" not found` - -Ensure `pdo` + `pdo_mysql` are installed on your PHP runtime. phpIP includes a compatibility fallback for SSL CA constant lookup, but DB drivers are still required. - -### C) OPS import works but US enrichment does not +### B) OPS import works but US enrichment does not Check: @@ -92,9 +88,13 @@ Check: - API key requirements for your ODP dataset - network egress to the endpoint host from your phpIP server -## 6) Security notes +## 6) Scope note (to avoid confusion) + +This guide is only about **OPS/USPTO family import behavior**. +Database/PDO runtime issues are separate deployment topics and are intentionally not covered here. + +## 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. - From 0f797687f2dbdce638f404923f3e2e85ffeb59f6 Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 15:35:36 -0500 Subject: [PATCH 06/14] Refocus USPTO guide on end-user usage only --- docs/USPTO_ODP.md | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md index 6e7dfd11..ee0fc39d 100644 --- a/docs/USPTO_ODP.md +++ b/docs/USPTO_ODP.md @@ -67,19 +67,9 @@ The orchestrator depends on OPS as the primary family source. If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. -## 5) Troubleshooting +## 5) Troubleshooting (USPTO ODP only) -### A) `Auth::routes()` / laravel-ui errors - -The app now defines auth routes explicitly and does not rely on the `Auth::routes()` macro. -If you still see old behavior, clear caches and redeploy updated code: - -```bash -php artisan optimize:clear -composer install --no-dev --optimize-autoloader -``` - -### B) OPS import works but US enrichment does not +### OPS import works but US enrichment does not Check: @@ -88,12 +78,7 @@ Check: - API key requirements for your ODP dataset - network egress to the endpoint host from your phpIP server -## 6) Scope note (to avoid confusion) - -This guide is only about **OPS/USPTO family import behavior**. -Database/PDO runtime issues are separate deployment topics and are intentionally not covered here. - -## 7) Security notes +## 6) Security notes - Keep API keys in `.env`, never in source files. - Restrict outbound network access from the server to approved API hosts only. From 7fb009685b2803b6219e1ae7cabe7010fd98c7fa Mon Sep 17 00:00:00 2001 From: srdco Date: Thu, 23 Apr 2026 16:50:50 -0500 Subject: [PATCH 07/14] Default USPTO ODP endpoints and remove mandatory endpoint setup --- app/Http/Controllers/MatterController.php | 18 +- app/Services/FamilyDataService.php | 80 +++++++ app/Services/OPSService.php | 26 ++- app/Services/USPTOService.php | 253 ++++++++++++++++++++++ composer.json | 1 - config/database.php | 2 +- config/services.php | 14 ++ doc/README.md | 6 + docs/USPTO_ODP.md | 81 +++++++ readme.md | 8 + routes/web.php | 28 ++- 11 files changed, 499 insertions(+), 18 deletions(-) create mode 100644 app/Services/FamilyDataService.php create mode 100644 app/Services/USPTOService.php create mode 100644 docs/USPTO_ODP.md diff --git a/app/Http/Controllers/MatterController.php b/app/Http/Controllers/MatterController.php index 366b64a1..75c600ed 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; @@ -26,24 +26,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; } /** @@ -359,7 +359,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); } @@ -444,7 +444,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( [ @@ -489,7 +489,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) == ',') { @@ -796,7 +796,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..d15ed9f2 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,14 +165,28 @@ 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.$'); + $inventors = collect($member[0]['exchange-document']['bibliographic-data']['parties']['inventors']['inventor'] ?? []) + ->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.$'); + $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + ->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..24d658d0 --- /dev/null +++ b/app/Services/USPTOService.php @@ -0,0 +1,253 @@ + $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 = Http::withHeaders($headers)->acceptJson()->get($url); + 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 = Http::withHeaders($headers)->acceptJson()->get($searchUrl, $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 = Http::withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + + return []; + } + + /** + * 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..34f023c6 --- /dev/null +++ b/docs/USPTO_ODP.md @@ -0,0 +1,81 @@ +# USPTO ODP integration guide + +This guide explains how phpIP uses USPTO Open Data Portal (ODP) data together with EPO OPS for patent family import. + +## 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) 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 +``` + +## 3) 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. + +## 4) 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. + +## 5) 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 + +## 6) 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..40233545 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,14 @@ 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 OPS as primary source, with optional USPTO ODP enrichment/fallback for US applications. + +The existing UI entry point remains unchanged: `Matters -> Create family from OPS`. + +Setup instructions are documented in [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 40612642..214a0eee 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'); From 05b0f3a70ee93c02c75f400db2f8a13547ab69a3 Mon Sep 17 00:00:00 2001 From: srdco Date: Fri, 24 Apr 2026 12:01:15 -0500 Subject: [PATCH 08/14] Make login controller version-agnostic across Laravel releases --- app/Http/Controllers/Auth/LoginController.php | 128 +++++++++++++++--- 1 file changed, 110 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index e45b5fd8..27c48136 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -3,29 +3,18 @@ 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\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 +34,87 @@ 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); + + if (! Auth::attempt($this->credentials($request), $request->has('remember'))) { + return $this->sendFailedLoginResponse($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 +122,29 @@ 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')], + ]); + } } From 64b54fefb259eb5e8dc0af21e7d04d48ba376f8b Mon Sep 17 00:00:00 2001 From: srdco Date: Mon, 4 May 2026 20:10:10 -0500 Subject: [PATCH 09/14] Handle duplicate matter-actor links when adding actors --- app/Http/Controllers/ActorPivotController.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/Http/Controllers/ActorPivotController.php b/app/Http/Controllers/ActorPivotController.php index 4b23d8e1..ae3fa82a 100644 --- a/app/Http/Controllers/ActorPivotController.php +++ b/app/Http/Controllers/ActorPivotController.php @@ -34,6 +34,15 @@ public function store(Request $request) 'date' => 'date', ]); + $existingLink = ActorPivot::where('matter_id', $request->matter_id) + ->where('role', $request->role) + ->where('actor_id', $request->actor_id) + ->first(); + + if ($existingLink) { + return $existingLink; + } + // Fix display order indexes if wrong $roleGroup = ActorPivot::where('matter_id', $request->matter_id)->where('role', $request->role); $max = $roleGroup->max('display_order'); From ef160d9d30a3cfdc4ee2dc1db95771f3aae092dd Mon Sep 17 00:00:00 2001 From: srdco Date: Wed, 1 Jul 2026 13:38:38 -0500 Subject: [PATCH 10/14] Add GitHub Actions workflow for OpenCode --- .github/workflows/opencode.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml new file mode 100644 index 00000000..0ce20f91 --- /dev/null +++ b/.github/workflows/opencode.yml @@ -0,0 +1,31 @@ +name: opencode + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + opencode: + if: | + contains(github.event.comment.body, '/oc') || + contains(github.event.comment.body, '/opencode') + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run OpenCode + uses: anomalyco/opencode/github@latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: anthropic/claude-sonnet-4-20250514 + # share: true + # github_token: xxxx From 594d72ceacd27660e68433e6b76d0701ad910287 Mon Sep 17 00:00:00 2001 From: srdco Date: Wed, 1 Jul 2026 14:03:07 -0500 Subject: [PATCH 11/14] Delete .github directory --- .github/workflows/opencode.yml | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 .github/workflows/opencode.yml diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml deleted file mode 100644 index 0ce20f91..00000000 --- a/.github/workflows/opencode.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: opencode - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - -jobs: - opencode: - if: | - contains(github.event.comment.body, '/oc') || - contains(github.event.comment.body, '/opencode') - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Run OpenCode - uses: anomalyco/opencode/github@latest - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - with: - model: anthropic/claude-sonnet-4-20250514 - # share: true - # github_token: xxxx From e2e870220367b740e8eba2c4cb255c53d7055497 Mon Sep 17 00:00:00 2001 From: srdco Date: Wed, 1 Jul 2026 14:23:40 -0500 Subject: [PATCH 12/14] Document USPTO ODP beta status --- app/Http/Controllers/ActorPivotController.php | 19 ++++----- app/Http/Controllers/Auth/LoginController.php | 41 +++++++++++++++++++ app/Services/OPSService.php | 6 ++- app/Services/USPTOService.php | 6 +-- docs/USPTO_ODP.md | 22 +++++++--- readme.md | 8 +++- 6 files changed, 80 insertions(+), 22 deletions(-) diff --git a/app/Http/Controllers/ActorPivotController.php b/app/Http/Controllers/ActorPivotController.php index ae3fa82a..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; @@ -34,15 +35,6 @@ public function store(Request $request) 'date' => 'date', ]); - $existingLink = ActorPivot::where('matter_id', $request->matter_id) - ->where('role', $request->role) - ->where('actor_id', $request->actor_id) - ->first(); - - if ($existingLink) { - return $existingLink; - } - // Fix display order indexes if wrong $roleGroup = ActorPivot::where('matter_id', $request->matter_id)->where('role', $request->role); $max = $roleGroup->max('display_order'); @@ -67,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 27c48136..f2a51962 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -5,6 +5,8 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; /** @@ -54,11 +56,15 @@ public function showLoginForm() 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')) { @@ -147,4 +153,39 @@ protected function sendFailedLoginResponse(Request $request) $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/Services/OPSService.php b/app/Services/OPSService.php index d15ed9f2..922e23cd 100644 --- a/app/Services/OPSService.php +++ b/app/Services/OPSService.php @@ -165,7 +165,8 @@ 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'] ?? []) + $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.$') @@ -177,7 +178,8 @@ public function getFamilyMembers(string $docnum): array } // Each applicant is under [i]['applicant-name']['name']['$'] - $applicants = collect($member[0]['exchange-document']['bibliographic-data']['parties']['applicants']['applicant'] ?? []) + $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.$') diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php index 24d658d0..7bef977d 100644 --- a/app/Services/USPTOService.php +++ b/app/Services/USPTOService.php @@ -89,7 +89,7 @@ public function getApplicationData(string $applicationNumber): array continue; } - $response = Http::withHeaders($headers)->acceptJson()->get($url); + $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($url); if ($response->successful()) { $normalized = $this->normalizeRecord($response->json()); if (!empty(array_filter($normalized))) { @@ -113,7 +113,7 @@ public function getApplicationData(string $applicationNumber): array 'size' => 1, ]; - $response = Http::withHeaders($headers)->acceptJson()->get($searchUrl, $queryStringPayload); + $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($searchUrl, $queryStringPayload); if ($response->successful()) { $normalized = $this->normalizeRecord($response->json()); if (!empty(array_filter($normalized))) { @@ -133,7 +133,7 @@ public function getApplicationData(string $applicationNumber): array 'size' => 1, ]; - $response = Http::withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); + $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); if ($response->successful()) { return $this->normalizeRecord($response->json()); } diff --git a/docs/USPTO_ODP.md b/docs/USPTO_ODP.md index 34f023c6..f8cc398a 100644 --- a/docs/USPTO_ODP.md +++ b/docs/USPTO_ODP.md @@ -2,6 +2,8 @@ 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: @@ -16,7 +18,17 @@ This means **you still use the same UI action**: No new UI menu is required. -## 2) Configuration +## 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`: @@ -42,7 +54,7 @@ Then clear Laravel config cache: php artisan optimize:clear ``` -## 3) Required existing OPS settings +## 4) Required existing OPS settings USPTO support does **not** replace OPS setup. Keep OPS credentials configured: @@ -53,7 +65,7 @@ OPS_SECRET=... The orchestrator depends on OPS as the primary family source. -## 4) Validation checklist +## 5) Validation checklist 1. Log in to phpIP. 2. Open `Matters -> Create family from OPS`. @@ -63,7 +75,7 @@ The orchestrator depends on OPS as the primary family source. If you have API access to USPTO ODP configured, US party/title/procedure fields may be enriched when missing in OPS. -## 5) Troubleshooting (USPTO ODP only) +## 6) Troubleshooting (USPTO ODP only) ### OPS import works but US enrichment does not @@ -74,7 +86,7 @@ Check: - API key requirements for your ODP dataset - network egress to the endpoint host from your phpIP server -## 6) Security notes +## 7) Security notes - Keep API keys in `.env`, never in source files. - Restrict outbound network access from the server to approved API hosts only. diff --git a/readme.md b/readme.md index 40233545..50f6208a 100644 --- a/readme.md +++ b/readme.md @@ -12,11 +12,15 @@ Head for the [Wiki](https://github.com/jjdejong/phpip/wiki) for further informat ## 2026-04-23 USPTO ODP fallback/enrichment for US family members -Family import now uses OPS as primary source, with optional USPTO ODP enrichment/fallback for US applications. +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`. -Setup instructions are documented in [USPTO ODP integration guide](docs/USPTO_ODP.md). +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 From f38ac2e8109935b06b91e47e3675de3a141450c2 Mon Sep 17 00:00:00 2001 From: srdco Date: Wed, 1 Jul 2026 16:09:40 -0500 Subject: [PATCH 13/14] Handle USPTO connection timeouts --- app/Services/USPTOService.php | 101 +++++++++++++++++--------------- tests/Unit/USPTOServiceTest.php | 27 +++++++++ 2 files changed, 80 insertions(+), 48 deletions(-) create mode 100644 tests/Unit/USPTOServiceTest.php diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php index 7bef977d..8ccb7bc9 100644 --- a/app/Services/USPTOService.php +++ b/app/Services/USPTOService.php @@ -2,6 +2,7 @@ namespace App\Services; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; @@ -74,68 +75,72 @@ public function getApplicationData(string $applicationNumber): array $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}', - ])); + try { + // 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) - ); + foreach ($templates as $template) { + $url = $this->resolveEndpointUrl( + str_replace('{applicationNumber}', $normalizedNumber, $template) + ); - if (empty($url)) { - continue; + if (empty($url)) { + continue; + } + + $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($url); + if ($response->successful()) { + $normalized = $this->normalizeRecord($response->json()); + if (!empty(array_filter($normalized))) { + return $normalized; + } + } } - $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($url); + // 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 = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($searchUrl, $queryStringPayload); 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 = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($searchUrl, $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]], + // Secondary fallback: POST JSON search payload. + $jsonSearchPayload = [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['term' => [$queryField => $normalizedNumber]], + ], ], ], - ], - 'size' => 1, - ]; + 'size' => 1, + ]; - $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); - if ($response->successful()) { - return $this->normalizeRecord($response->json()); + $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); + if ($response->successful()) { + return $this->normalizeRecord($response->json()); + } + } catch (ConnectionException) { + return []; } return []; diff --git a/tests/Unit/USPTOServiceTest.php b/tests/Unit/USPTOServiceTest.php new file mode 100644 index 00000000..677b3412 --- /dev/null +++ b/tests/Unit/USPTOServiceTest.php @@ -0,0 +1,27 @@ + 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')); + } +} From 764812847c7efdac1788fe588f56f4a0ab0fd1e0 Mon Sep 17 00:00:00 2001 From: srdco Date: Wed, 1 Jul 2026 17:13:55 -0500 Subject: [PATCH 14/14] Preserve USPTO fallback requests after timeouts --- app/Services/USPTOService.php | 132 +++++++++++++++++++------------- tests/Unit/USPTOServiceTest.php | 30 ++++++++ 2 files changed, 109 insertions(+), 53 deletions(-) diff --git a/app/Services/USPTOService.php b/app/Services/USPTOService.php index 8ccb7bc9..f248bb5e 100644 --- a/app/Services/USPTOService.php +++ b/app/Services/USPTOService.php @@ -75,77 +75,103 @@ public function getApplicationData(string $applicationNumber): array $apiKey = config('services.uspto.api_key'); $headers = $apiKey ? ['X-Api-Key' => $apiKey] : []; - try { - // 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; - } + // 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}', + ])); - $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($url); - 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' + foreach ($templates as $template) { + $url = $this->resolveEndpointUrl( + str_replace('{applicationNumber}', $normalizedNumber, $template) ); - if (empty($searchUrl)) { - return []; - } - $queryField = config('services.uspto.search_field', 'applicationNumberText'); - $queryStringPayload = [ - 'q' => sprintf('%s:"%s"', $queryField, $normalizedNumber), - 'size' => 1, - ]; + if (empty($url)) { + continue; + } - $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->get($searchUrl, $queryStringPayload); - if ($response->successful()) { + $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]], - ], + // Secondary fallback: POST JSON search payload. + $jsonSearchPayload = [ + 'query' => [ + 'bool' => [ + 'must' => [ + ['term' => [$queryField => $normalizedNumber]], ], ], - 'size' => 1, - ]; + ], + 'size' => 1, + ]; - $response = Http::timeout(10)->withHeaders($headers)->acceptJson()->post($searchUrl, $jsonSearchPayload); - if ($response->successful()) { - return $this->normalizeRecord($response->json()); - } - } catch (ConnectionException) { - return []; + $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. * diff --git a/tests/Unit/USPTOServiceTest.php b/tests/Unit/USPTOServiceTest.php index 677b3412..9c5ddd9d 100644 --- a/tests/Unit/USPTOServiceTest.php +++ b/tests/Unit/USPTOServiceTest.php @@ -24,4 +24,34 @@ public function testGetApplicationDataReturnsEmptyArrayWhenUsptoRequestTimesOut( $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')); + } }