feat: extract business providers from app/Plug/Business

GoogleMyBusiness provider with Core\Plug\Business namespace alignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 17:28:57 +00:00
parent 599039f283
commit f6491f075e
6 changed files with 653 additions and 0 deletions

17
composer.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "core/php-plug-business",
"description": "Business platform integrations for the Plug framework",
"type": "library",
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"core/php": "^1.0"
},
"autoload": {
"psr-4": {
"Core\\Plug\\Business\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

View file

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Business\GoogleMyBusiness;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Authenticable;
use Core\Plug\Contract\Refreshable;
use Core\Plug\Response;
/**
* Google My Business OAuth 2.0 authentication.
*/
class Auth implements Authenticable, Refreshable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
private string $clientId;
private string $clientSecret;
private string $redirectUrl;
private array $scope = [
'https://www.googleapis.com/auth/business.manage',
];
public function __construct(string $clientId = '', string $clientSecret = '', string $redirectUrl = '')
{
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->redirectUrl = $redirectUrl;
}
public static function identifier(): string
{
return 'googlemybusiness';
}
public static function name(): string
{
return 'Google My Business';
}
/**
* Get OAuth URL.
*/
public function getAuthUrl(): string
{
$params = [
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUrl,
'scope' => implode(' ', $this->scope),
'response_type' => 'code',
'access_type' => 'offline',
'prompt' => 'consent',
'include_granted_scopes' => 'true',
];
return self::AUTH_URL.'?'.http_build_query($params);
}
/**
* Exchange code for access token.
*
* @param array $params ['code' => string]
*/
public function requestAccessToken(array $params): array
{
if (! isset($params['code'])) {
return ['error' => 'Authorization code is required'];
}
$response = $this->http()
->asForm()
->post(self::TOKEN_URL, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'redirect_uri' => $this->redirectUrl,
'grant_type' => 'authorization_code',
'code' => $params['code'],
]);
if (! $response->successful()) {
$error = $response->json();
return ['error' => $error['error_description'] ?? $error['error'] ?? 'Token exchange failed'];
}
$data = $response->json();
return [
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token'] ?? '',
'expires_in' => now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp,
'token_type' => $data['token_type'] ?? 'Bearer',
'scope' => $data['scope'] ?? '',
];
}
/**
* Refresh the access token.
*/
public function refresh(): Response
{
$refreshToken = $this->token['refresh_token'] ?? null;
if (! $refreshToken) {
return $this->error('No refresh token available');
}
$response = $this->http()
->asForm()
->post(self::TOKEN_URL, [
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
]);
return $this->fromHttp($response, function ($data) use ($refreshToken) {
return [
'access_token' => $data['access_token'],
'refresh_token' => $refreshToken, // Google doesn't return new refresh token
'expires_in' => now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp,
'token_type' => $data['token_type'] ?? 'Bearer',
];
});
}
public function getAccount(): Response
{
return (new Read)->withToken($this->token)->me();
}
/**
* Google Business Profile URL.
*/
public static function externalAccountUrl(string $placeId): string
{
if ($placeId) {
return "https://www.google.com/maps/place/?q=place_id:{$placeId}";
}
return 'https://business.google.com';
}
}

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Business\GoogleMyBusiness;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Deletable;
use Core\Plug\Response;
/**
* Google My Business local post deletion.
*/
class Delete implements Deletable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://mybusiness.googleapis.com/v4';
/**
* Delete a local post.
*
* @param string $id Full post resource name (e.g., accounts/xxx/locations/xxx/localPosts/xxx)
*/
public function delete(string $id): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->delete(self::API_URL."/{$id}");
// GMB returns 200 with empty body on success
if ($response->successful()) {
return $this->ok([
'deleted' => true,
'id' => $id,
]);
}
return $this->fromHttp($response, fn ($data) => [
'deleted' => false,
'error' => $data['error']['message'] ?? 'Failed to delete post',
]);
}
}

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Business\GoogleMyBusiness;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Listable;
use Core\Plug\Response;
/**
* Google My Business locations listing.
*/
class Locations implements Listable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const BUSINESS_API_URL = 'https://mybusinessbusinessinformation.googleapis.com/v1';
private ?string $accountName = null;
/**
* Set the account to list locations for.
*/
public function forAccount(string $accountName): self
{
$this->accountName = $accountName;
return $this;
}
/**
* List business locations.
*/
public function listEntities(): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
if (! $this->accountName) {
return $this->error('Account name is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::BUSINESS_API_URL."/{$this->accountName}/locations", [
'readMask' => 'name,title,storefrontAddress,metadata,phoneNumbers,websiteUri',
]);
return $this->fromHttp($response, function ($data) {
return [
'locations' => array_map(fn ($location) => [
'id' => $location['name'] ?? '',
'title' => $location['title'] ?? '',
'address' => $this->formatAddress($location['storefrontAddress'] ?? []),
'place_id' => $location['metadata']['placeId'] ?? '',
'maps_url' => $location['metadata']['mapsUri'] ?? '',
'phone' => $location['phoneNumbers']['primaryPhone'] ?? null,
'website' => $location['websiteUri'] ?? null,
], $data['locations'] ?? []),
];
});
}
/**
* Get a specific location.
*/
public function get(string $locationName): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::BUSINESS_API_URL."/{$locationName}", [
'readMask' => 'name,title,storefrontAddress,metadata,phoneNumbers,websiteUri,regularHours,specialHours',
]);
return $this->fromHttp($response, function ($data) {
return [
'id' => $data['name'] ?? '',
'title' => $data['title'] ?? '',
'address' => $this->formatAddress($data['storefrontAddress'] ?? []),
'place_id' => $data['metadata']['placeId'] ?? '',
'maps_url' => $data['metadata']['mapsUri'] ?? '',
'phone' => $data['phoneNumbers']['primaryPhone'] ?? null,
'website' => $data['websiteUri'] ?? null,
'regular_hours' => $data['regularHours'] ?? null,
'special_hours' => $data['specialHours'] ?? null,
];
});
}
/**
* Format address array to string.
*/
private function formatAddress(array $address): string
{
$parts = array_filter([
$address['addressLines'][0] ?? '',
$address['locality'] ?? '',
$address['administrativeArea'] ?? '',
$address['postalCode'] ?? '',
$address['regionCode'] ?? '',
]);
return implode(', ', $parts);
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Business\GoogleMyBusiness;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Postable;
use Core\Plug\Response;
use Illuminate\Support\Collection;
/**
* Google My Business local post publishing.
*/
class Post implements Postable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://mybusiness.googleapis.com/v4';
/**
* Publish a local post to GMB.
*
* @param string $text Post summary text
* @param Collection $media Media items (photos only, no video support)
* @param array $params location (required), post_type, cta_type, cta_url, event_*, coupon_code, etc.
*/
public function publish(string $text, Collection $media, array $params = []): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$location = $params['location'] ?? '';
if (! $location) {
return $this->error('Location is required for Google My Business posts');
}
$postType = $params['post_type'] ?? 'STANDARD';
$payload = $this->buildPayload($text, $media, $postType, $params);
$response = $this->http()
->withToken($accessToken)
->post(self::API_URL."/{$location}/localPosts", $payload);
return $this->fromHttp($response, fn ($data) => [
'id' => $data['name'] ?? '',
'state' => $data['state'] ?? 'LIVE',
'url' => $data['searchUrl'] ?? null,
]);
}
/**
* Build post payload based on type.
*/
private function buildPayload(string $text, Collection $media, string $postType, array $params): array
{
$payload = [
'languageCode' => $params['language'] ?? 'en',
'summary' => $text,
'topicType' => $postType,
];
// Add media (GMB only supports photos)
if ($media->isNotEmpty()) {
$mediaItem = $media->first();
$url = is_array($mediaItem) ? ($mediaItem['url'] ?? '') : $mediaItem;
if ($url) {
$payload['media'] = [
'mediaFormat' => 'PHOTO',
'sourceUrl' => $url,
];
}
}
// Call to action
if (! empty($params['cta_type']) && ! empty($params['cta_url'])) {
$payload['callToAction'] = [
'actionType' => $params['cta_type'], // BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, CALL
'url' => $params['cta_url'],
];
}
// Event details
if ($postType === 'EVENT' && ! empty($params['event_title'])) {
$payload['event'] = [
'title' => $params['event_title'],
'schedule' => [],
];
if (! empty($params['event_start'])) {
$payload['event']['schedule']['startDate'] = $this->formatDate($params['event_start']);
$payload['event']['schedule']['startTime'] = $this->formatTime($params['event_start']);
}
if (! empty($params['event_end'])) {
$payload['event']['schedule']['endDate'] = $this->formatDate($params['event_end']);
$payload['event']['schedule']['endTime'] = $this->formatTime($params['event_end']);
}
}
// Offer details
if ($postType === 'OFFER') {
$payload['offer'] = [];
if (! empty($params['coupon_code'])) {
$payload['offer']['couponCode'] = $params['coupon_code'];
}
if (! empty($params['redeem_url'])) {
$payload['offer']['redeemOnlineUrl'] = $params['redeem_url'];
}
if (! empty($params['terms'])) {
$payload['offer']['termsConditions'] = $params['terms'];
}
}
return $payload;
}
/**
* Format datetime to GMB date array.
*/
private function formatDate(string $datetime): array
{
$date = new \DateTime($datetime);
return [
'year' => (int) $date->format('Y'),
'month' => (int) $date->format('m'),
'day' => (int) $date->format('d'),
];
}
/**
* Format datetime to GMB time array.
*/
private function formatTime(string $datetime): array
{
$date = new \DateTime($datetime);
return [
'hours' => (int) $date->format('H'),
'minutes' => (int) $date->format('i'),
'seconds' => 0,
'nanos' => 0,
];
}
/**
* GMB posts don't have public URLs.
*/
public static function externalPostUrl(string $locationName, string $postId): string
{
return '';
}
}

View file

@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Business\GoogleMyBusiness;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\ManagesTokens;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Contract\Readable;
use Core\Plug\Response;
/**
* Google My Business account and location reading.
*/
class Read implements Readable
{
use BuildsResponse;
use ManagesTokens;
use UsesHttp;
private const API_URL = 'https://mybusiness.googleapis.com/v4';
private const BUSINESS_API_URL = 'https://mybusinessbusinessinformation.googleapis.com/v1';
/**
* Get a specific local post.
*
* @param string $id Full post resource name
*/
public function get(string $id): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::API_URL."/{$id}");
return $this->fromHttp($response, fn ($data) => [
'id' => $data['name'] ?? '',
'summary' => $data['summary'] ?? '',
'state' => $data['state'] ?? '',
'topic_type' => $data['topicType'] ?? '',
'create_time' => $data['createTime'] ?? null,
'update_time' => $data['updateTime'] ?? null,
'media' => $data['media'] ?? null,
'call_to_action' => $data['callToAction'] ?? null,
]);
}
/**
* Get current account info.
*/
public function me(): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::API_URL.'/accounts');
return $this->fromHttp($response, function ($data) {
$accounts = $data['accounts'] ?? [];
if (empty($accounts)) {
return [
'id' => null,
'name' => null,
'error' => 'No business accounts found',
];
}
// Return first account as primary
$account = $accounts[0];
return [
'id' => $account['name'] ?? '',
'name' => $account['accountName'] ?? 'Google Business Profile',
'type' => $account['type'] ?? '',
'role' => $account['role'] ?? '',
'state' => $account['state']['status'] ?? '',
];
});
}
/**
* List accounts.
*/
public function list(array $params = []): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::API_URL.'/accounts');
return $this->fromHttp($response, function ($data) {
return [
'accounts' => array_map(fn ($account) => [
'id' => $account['name'] ?? '',
'name' => $account['accountName'] ?? '',
'type' => $account['type'] ?? '',
'role' => $account['role'] ?? '',
], $data['accounts'] ?? []),
];
});
}
/**
* List local posts for a location.
*/
public function posts(string $locationName, array $params = []): Response
{
$accessToken = $this->accessToken();
if (! $accessToken) {
return $this->error('Access token is required');
}
$response = $this->http()
->withToken($accessToken)
->get(self::API_URL."/{$locationName}/localPosts", [
'pageSize' => $params['per_page'] ?? 10,
'pageToken' => $params['page_token'] ?? null,
]);
return $this->fromHttp($response, fn ($data) => [
'posts' => array_map(fn ($post) => [
'id' => $post['name'] ?? '',
'summary' => $post['summary'] ?? '',
'state' => $post['state'] ?? '',
'topic_type' => $post['topicType'] ?? '',
'create_time' => $post['createTime'] ?? null,
], $data['localPosts'] ?? []),
'next_page_token' => $data['nextPageToken'] ?? null,
]);
}
}