Init
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
65
.env.example
Normal file
65
.env.example
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=laravel
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
|
/rr
|
||||||
11
.rr.yaml
Normal file
11
.rr.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
version: "3"
|
||||||
|
rpc:
|
||||||
|
listen: tcp://127.0.0.1:6001
|
||||||
|
server:
|
||||||
|
command: "php worker.php"
|
||||||
|
temporal:
|
||||||
|
address: "temporal:7233"
|
||||||
|
activities:
|
||||||
|
num_workers: 4
|
||||||
|
logs:
|
||||||
|
level: info
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
836
app/Http/Controllers/TemporalDemoController.php
Normal file
836
app/Http/Controllers/TemporalDemoController.php
Normal file
@@ -0,0 +1,836 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ImportJob;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Temporal\DataEnrichment\DataEnrichmentWorkflowInterface;
|
||||||
|
use App\Temporal\EloquentQuery\EloquentQueryWorkflowInterface;
|
||||||
|
use App\Temporal\ExternalApiSync\ExternalApiSyncWorkflowInterface;
|
||||||
|
use App\Temporal\OrderFulfillment\OrderFulfillmentWorkflowInterface;
|
||||||
|
use App\Temporal\ProductImport\ProductImportWorkflowInterface;
|
||||||
|
use App\Temporal\SystemMonitor\SystemMonitorWorkflowInterface;
|
||||||
|
use App\Temporal\UserMigration\UserMigrationWorkflowInterface;
|
||||||
|
use App\Temporal\WebhookDelivery\WebhookDeliveryWorkflowInterface;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
use Temporal\Client\GRPC\ServiceClient;
|
||||||
|
use Temporal\Client\WorkflowClient;
|
||||||
|
use Temporal\Client\WorkflowClientInterface;
|
||||||
|
use Temporal\Client\WorkflowOptions;
|
||||||
|
use Temporal\Workflow\WorkflowExecutionStatus;
|
||||||
|
|
||||||
|
class TemporalDemoController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkflowClientInterface $workflowClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function dashboard(): InertiaResponse
|
||||||
|
{
|
||||||
|
$importJobs = ImportJob::latest()->take(10)->get();
|
||||||
|
$orders = Order::latest()->take(10)->get();
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'importJobs' => $importJobs,
|
||||||
|
'orders' => $orders,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reset & Terminate ---
|
||||||
|
|
||||||
|
public function reset(): StreamedResponse
|
||||||
|
{
|
||||||
|
return $this->streamedOperation(function () {
|
||||||
|
$this->emit('Starting full reset...', 'step');
|
||||||
|
|
||||||
|
// 1. Look up container IDs (before dropping DBs, which may crash them)
|
||||||
|
$containerId = null;
|
||||||
|
$workerContainerId = null;
|
||||||
|
try {
|
||||||
|
$containerId = $this->findDockerContainer('temporal');
|
||||||
|
$workerContainerId = $this->findDockerContainer('temporal-worker');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Docker lookup failed: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Drop Temporal databases (all workflow history will be wiped)
|
||||||
|
$this->emit('Dropping Temporal databases...', 'step');
|
||||||
|
try {
|
||||||
|
$pdo = new \PDO('pgsql:host=temporal-pgsql;port=5432;dbname=postgres', 'temporal', 'temporal');
|
||||||
|
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
foreach (['temporal', 'temporal_visibility'] as $db) {
|
||||||
|
$pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{$db}' AND pid <> pg_backend_pid()");
|
||||||
|
$pdo->exec("DROP DATABASE IF EXISTS {$db}");
|
||||||
|
$pdo->exec("CREATE DATABASE {$db} OWNER temporal");
|
||||||
|
$this->emit("Recreated database: {$db}");
|
||||||
|
}
|
||||||
|
$this->emit('Temporal databases reset', 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Database reset failed: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Restart Temporal container (using pre-fetched ID)
|
||||||
|
$this->emit('Restarting Temporal container...', 'step');
|
||||||
|
try {
|
||||||
|
$this->restartDockerContainer($containerId);
|
||||||
|
$this->emit('Restart signal sent');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Docker restart failed: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Wait for Temporal to come back
|
||||||
|
$this->emit('Waiting for Temporal to come back online...', 'step');
|
||||||
|
$start = time();
|
||||||
|
$online = false;
|
||||||
|
$lastUpdate = 0;
|
||||||
|
while (time() - $start < 30) {
|
||||||
|
try {
|
||||||
|
$client = WorkflowClient::create(
|
||||||
|
ServiceClient::create(config('temporal.address'))
|
||||||
|
);
|
||||||
|
$client->listWorkflowExecutions('ExecutionStatus="Running"', pageSize: 1);
|
||||||
|
$online = true;
|
||||||
|
break;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$elapsed = time() - $start;
|
||||||
|
if ($elapsed - $lastUpdate >= 3) {
|
||||||
|
$this->emit("Still waiting... ({$elapsed}s)");
|
||||||
|
$lastUpdate = $elapsed;
|
||||||
|
}
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($online) {
|
||||||
|
$this->emit('Temporal is back online', 'success');
|
||||||
|
} else {
|
||||||
|
$this->emit('Temporal did not come back within 30s — may need manual restart', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Restart worker
|
||||||
|
$this->emit('Restarting worker...', 'step');
|
||||||
|
try {
|
||||||
|
$this->restartDockerContainer($workerContainerId);
|
||||||
|
$this->emit('Worker restarted', 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Worker restart failed: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Reset Laravel database
|
||||||
|
$this->emit('Running migrate:fresh --seed...', 'step');
|
||||||
|
try {
|
||||||
|
Artisan::call('migrate:fresh', ['--seed' => true, '--seeder' => 'TemporalDemoSeeder', '--force' => true]);
|
||||||
|
$this->emit('Database reset and seeded', 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Migration failed: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emit('Reset complete', 'done');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function terminateAll(): StreamedResponse
|
||||||
|
{
|
||||||
|
return $this->streamedOperation(function () {
|
||||||
|
$this->emit('Listing running workflows...', 'step');
|
||||||
|
$terminated = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$paginator = $this->workflowClient->listWorkflowExecutions(
|
||||||
|
'ExecutionStatus="Running"',
|
||||||
|
pageSize: 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($paginator as $info) {
|
||||||
|
try {
|
||||||
|
$wfId = $info->execution->getID();
|
||||||
|
$stub = $this->workflowClient->newUntypedRunningWorkflowStub(
|
||||||
|
$wfId,
|
||||||
|
$info->execution->getRunID(),
|
||||||
|
);
|
||||||
|
$stub->terminate('Manual termination from dashboard');
|
||||||
|
$terminated++;
|
||||||
|
$this->emit("Terminated: {$wfId}", 'warn');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Already completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emit("Terminated {$terminated} workflow(s)", 'success');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->emit("Failed to connect to Temporal: {$e->getMessage()}", 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->emit($terminated > 0 ? 'All workflows terminated' : 'No running workflows found', 'done');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check actual execution status via Temporal's DescribeWorkflowExecution.
|
||||||
|
* Returns a terminal status string, or null if the workflow is still running.
|
||||||
|
*/
|
||||||
|
private function resolveWorkflowStatus(string $workflowId): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newUntypedRunningWorkflowStub($workflowId);
|
||||||
|
$description = $stub->describe();
|
||||||
|
|
||||||
|
return match ($description->info->status) {
|
||||||
|
WorkflowExecutionStatus::Completed => 'completed',
|
||||||
|
WorkflowExecutionStatus::Failed, WorkflowExecutionStatus::TimedOut => 'failed',
|
||||||
|
WorkflowExecutionStatus::Canceled, WorkflowExecutionStatus::Terminated => 'cancelled',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Workflow not found in Temporal — treat as failed
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function streamedOperation(\Closure $callback): StreamedResponse
|
||||||
|
{
|
||||||
|
return response()->stream(function () use ($callback) {
|
||||||
|
while (ob_get_level()) {
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
$callback();
|
||||||
|
}, 200, [
|
||||||
|
'Content-Type' => 'text/plain',
|
||||||
|
'Cache-Control' => 'no-cache',
|
||||||
|
'X-Accel-Buffering' => 'no',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emit(string $message, string $type = 'info'): void
|
||||||
|
{
|
||||||
|
echo json_encode([
|
||||||
|
'type' => $type,
|
||||||
|
'message' => $message,
|
||||||
|
'time' => date('H:i:s'),
|
||||||
|
]) . "\n";
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findDockerContainer(string $serviceName): ?string
|
||||||
|
{
|
||||||
|
$socketPath = '/var/run/docker.sock';
|
||||||
|
if (!file_exists($socketPath)) {
|
||||||
|
$this->emit('Docker socket not found — cannot restart automatically', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = urlencode(json_encode([
|
||||||
|
'label' => [
|
||||||
|
"com.docker.compose.service={$serviceName}",
|
||||||
|
'com.docker.compose.project=temporalio-test',
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response = $this->dockerApiGet("/containers/json?all=true&filters={$filters}");
|
||||||
|
$containers = json_decode($response, true);
|
||||||
|
|
||||||
|
if (empty($containers)) {
|
||||||
|
$this->emit("Container for service '{$serviceName}' not found", 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$containerId = $containers[0]['Id'];
|
||||||
|
$containerName = ltrim($containers[0]['Names'][0] ?? $containerId, '/');
|
||||||
|
$this->emit("Found container: {$containerName}");
|
||||||
|
|
||||||
|
return $containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function restartDockerContainer(?string $containerId): void
|
||||||
|
{
|
||||||
|
if (!$containerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->dockerApiPost("/containers/{$containerId}/restart?t=5");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dockerApiGet(string $path): string
|
||||||
|
{
|
||||||
|
return $this->dockerApiRequest('GET', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dockerApiPost(string $path): string
|
||||||
|
{
|
||||||
|
return $this->dockerApiRequest('POST', $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dockerApiRequest(string $method, string $path): string
|
||||||
|
{
|
||||||
|
$socket = stream_socket_client('unix:///var/run/docker.sock', $errno, $errstr, 5);
|
||||||
|
if (!$socket) {
|
||||||
|
$this->emit("Docker socket connection failed: {$errstr}", 'error');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_timeout($socket, 30);
|
||||||
|
|
||||||
|
$request = "{$method} {$path} HTTP/1.0\r\nHost: localhost\r\n\r\n";
|
||||||
|
fwrite($socket, $request);
|
||||||
|
|
||||||
|
$response = '';
|
||||||
|
while (!feof($socket)) {
|
||||||
|
$chunk = fread($socket, 8192);
|
||||||
|
if ($chunk === false) break;
|
||||||
|
$meta = stream_get_meta_data($socket);
|
||||||
|
if ($meta['timed_out']) {
|
||||||
|
$this->emit('Docker API request timed out', 'error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$response .= $chunk;
|
||||||
|
}
|
||||||
|
fclose($socket);
|
||||||
|
|
||||||
|
// Strip HTTP headers — body comes after \r\n\r\n
|
||||||
|
$parts = explode("\r\n\r\n", $response, 2);
|
||||||
|
return $parts[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Product Import ---
|
||||||
|
|
||||||
|
public function startImport(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$filePath = storage_path('app/imports/products.csv');
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
return response()->json(['error' => 'CSV file not found. Run TemporalDemoSeeder first.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
$workflowId = 'product-import-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
ProductImportWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $filePath, $simulationConfig);
|
||||||
|
|
||||||
|
$importJob = ImportJob::create([
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'type' => 'product_import',
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'status' => 'started',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Product import started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'import_job_id' => $importJob->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseImport(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ProductImportWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->pause();
|
||||||
|
|
||||||
|
$importJob->update(['status' => 'paused']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Import paused', 'workflow_id' => $importJob->workflow_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeImport(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ProductImportWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->resume();
|
||||||
|
|
||||||
|
$importJob->update(['status' => 'processing']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Import resumed', 'workflow_id' => $importJob->workflow_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function importStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ProductImportWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = $stub->getStatus();
|
||||||
|
|
||||||
|
// Query handler returns cached internal state — cross-check
|
||||||
|
// actual execution status when the query says non-terminal
|
||||||
|
if (!in_array($status['status'], ['completed', 'cancelled', 'failed'])) {
|
||||||
|
$resolved = $this->resolveWorkflowStatus($importJob->workflow_id);
|
||||||
|
if ($resolved !== null) {
|
||||||
|
$status['status'] = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$importJob->update([
|
||||||
|
'status' => $status['status'],
|
||||||
|
'total_records' => $status['totalRecords'] ?? 0,
|
||||||
|
'processed_records' => $status['processedRecords'] ?? 0,
|
||||||
|
'failed_records' => $status['failedRecords'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($status);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$resolvedStatus = $this->resolveWorkflowStatus($importJob->workflow_id);
|
||||||
|
if ($resolvedStatus !== null) {
|
||||||
|
$importJob->update(['status' => $resolvedStatus]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $resolvedStatus ?? $importJob->status,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Order Fulfillment ---
|
||||||
|
|
||||||
|
public function processOrder(Request $request, int $orderId): JsonResponse
|
||||||
|
{
|
||||||
|
$order = Order::findOrFail($orderId);
|
||||||
|
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
$workflowId = 'order-fulfillment-' . $orderId;
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
OrderFulfillmentWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $orderId, $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Order processing started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'order_id' => $orderId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shipOrder(int $orderId, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$trackingNumber = $request->input('tracking_number', 'TRACK-' . strtoupper(uniqid()));
|
||||||
|
$workflowId = 'order-fulfillment-' . $orderId;
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
OrderFulfillmentWorkflowInterface::class,
|
||||||
|
$workflowId
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->confirmShipping($trackingNumber);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Shipping confirmation sent',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderStatus(int $orderId): JsonResponse
|
||||||
|
{
|
||||||
|
$workflowId = 'order-fulfillment-' . $orderId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
OrderFulfillmentWorkflowInterface::class,
|
||||||
|
$workflowId
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = $stub->getOrderStatus();
|
||||||
|
|
||||||
|
return response()->json($status);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'order_id' => $orderId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- User Migration ---
|
||||||
|
|
||||||
|
public function startMigration(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$totalUsers = $request->input('total_users', 100);
|
||||||
|
$batchSize = $request->input('batch_size', 20);
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
|
||||||
|
$workflowId = 'user-migration-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
UserMigrationWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, (int) $totalUsers, (int) $batchSize, $simulationConfig);
|
||||||
|
|
||||||
|
$importJob = ImportJob::create([
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'type' => 'user_migration',
|
||||||
|
'status' => 'started',
|
||||||
|
'total_records' => $totalUsers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'User migration started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'import_job_id' => $importJob->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseMigration(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
UserMigrationWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->pause();
|
||||||
|
|
||||||
|
$importJob->update(['status' => 'paused']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Migration paused', 'workflow_id' => $importJob->workflow_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeMigration(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
UserMigrationWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->resume();
|
||||||
|
|
||||||
|
$importJob->update(['status' => 'processing']);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Migration resumed', 'workflow_id' => $importJob->workflow_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrationStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$importJob = ImportJob::findOrFail($id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
UserMigrationWorkflowInterface::class,
|
||||||
|
$importJob->workflow_id
|
||||||
|
);
|
||||||
|
|
||||||
|
$status = $stub->getProgress();
|
||||||
|
|
||||||
|
// Query handler returns cached internal state — cross-check
|
||||||
|
// actual execution status when the query says non-terminal
|
||||||
|
if (!in_array($status['status'], ['completed', 'cancelled', 'failed'])) {
|
||||||
|
$resolved = $this->resolveWorkflowStatus($importJob->workflow_id);
|
||||||
|
if ($resolved !== null) {
|
||||||
|
$status['status'] = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$importJob->update([
|
||||||
|
'status' => $status['status'],
|
||||||
|
'processed_records' => $status['processedUsers'] ?? 0,
|
||||||
|
'failed_records' => $status['failedUsers'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($status);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$resolvedStatus = $this->resolveWorkflowStatus($importJob->workflow_id);
|
||||||
|
if ($resolvedStatus !== null) {
|
||||||
|
$importJob->update(['status' => $resolvedStatus]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $resolvedStatus ?? $importJob->status,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- External API Sync ---
|
||||||
|
|
||||||
|
public function startApiSync(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$apiEndpoint = $request->input('api_endpoint', 'https://api.example.com/products');
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
|
||||||
|
$workflowId = 'api-sync-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
ExternalApiSyncWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $apiEndpoint, $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'API sync started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apiSyncStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ExternalApiSyncWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($stub->getProgress());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pauseApiSync(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ExternalApiSyncWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
$stub->pause();
|
||||||
|
return response()->json(['message' => 'API sync paused']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resumeApiSync(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
ExternalApiSyncWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
$stub->resume();
|
||||||
|
return response()->json(['message' => 'API sync resumed']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Webhook Delivery ---
|
||||||
|
|
||||||
|
public function deliverWebhooks(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->input('payload', ['event' => 'order.created', 'data' => ['order_id' => 1]]);
|
||||||
|
$endpoints = $request->input('endpoints', [
|
||||||
|
'https://api.example.com/webhook',
|
||||||
|
'https://hooks.partner.io/events',
|
||||||
|
'https://notify.service.dev/incoming',
|
||||||
|
'https://webhook.site/test-endpoint',
|
||||||
|
]);
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
|
||||||
|
$workflowId = 'webhook-delivery-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
WebhookDeliveryWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $payload, $endpoints, $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Webhook delivery started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'endpoints_count' => count($endpoints),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhookStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
WebhookDeliveryWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($stub->getDeliveryStatus());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Enrichment ---
|
||||||
|
|
||||||
|
public function startEnrichment(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$recordIds = $request->input('record_ids', []);
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
|
||||||
|
// Default to first 5 orders if no IDs provided
|
||||||
|
if (empty($recordIds)) {
|
||||||
|
$recordIds = Order::orderBy('id')->take(5)->pluck('id')->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$workflowId = 'data-enrichment-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
DataEnrichmentWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $recordIds, $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Data enrichment started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
'record_count' => count($recordIds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enrichmentStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
DataEnrichmentWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($stub->getProgress());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eloquent Query Pipeline ---
|
||||||
|
|
||||||
|
public function startEloquentQuery(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
$workflowId = 'eloquent-query-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
EloquentQueryWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Eloquent query pipeline started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eloquentQueryStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
EloquentQueryWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($stub->getProgress());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- System Health Monitor ---
|
||||||
|
|
||||||
|
public function startSystemMonitor(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$intervalSeconds = $request->input('interval_seconds', 60);
|
||||||
|
$maxIterations = $request->input('max_iterations', 30);
|
||||||
|
$simulationConfig = $request->input('simulation', []);
|
||||||
|
|
||||||
|
$workflowId = 'system-monitor-' . uniqid();
|
||||||
|
|
||||||
|
$stub = $this->workflowClient->newWorkflowStub(
|
||||||
|
SystemMonitorWorkflowInterface::class,
|
||||||
|
WorkflowOptions::new()
|
||||||
|
->withWorkflowId($workflowId)
|
||||||
|
->withTaskQueue(config('temporal.task_queue'))
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = $this->workflowClient->start($stub, (int) $intervalSeconds, (int) $maxIterations, [], $simulationConfig);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'System monitor started',
|
||||||
|
'workflow_id' => $workflowId,
|
||||||
|
'run_id' => $run->getExecution()->getRunID(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function systemMonitorStatus(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
SystemMonitorWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($stub->getStatus());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stopSystemMonitor(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stub = $this->workflowClient->newRunningWorkflowStub(
|
||||||
|
SystemMonitorWorkflowInterface::class,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
$stub->stop();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Stop signal sent']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Middleware/HandleInertiaRequests.php
Normal file
43
app/Http/Middleware/HandleInertiaRequests.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that's loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/server-side-setup#root-template
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the current asset version.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/asset-versioning
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/shared-data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/EnrichmentResult.php
Normal file
23
app/Models/EnrichmentResult.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class EnrichmentResult extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'record_type',
|
||||||
|
'record_id',
|
||||||
|
'geocode_result',
|
||||||
|
'email_valid',
|
||||||
|
'credit_score',
|
||||||
|
'enriched_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'geocode_result' => 'array',
|
||||||
|
'email_valid' => 'boolean',
|
||||||
|
'enriched_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
24
app/Models/ImportJob.php
Normal file
24
app/Models/ImportJob.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ImportJob extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'workflow_id',
|
||||||
|
'run_id',
|
||||||
|
'type',
|
||||||
|
'file_path',
|
||||||
|
'status',
|
||||||
|
'total_records',
|
||||||
|
'processed_records',
|
||||||
|
'failed_records',
|
||||||
|
'error_log',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'error_log' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
28
app/Models/Order.php
Normal file
28
app/Models/Order.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Order extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'order_number',
|
||||||
|
'customer_name',
|
||||||
|
'customer_email',
|
||||||
|
'status',
|
||||||
|
'total_amount',
|
||||||
|
'payment_id',
|
||||||
|
'tracking_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'total_amount' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function items(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OrderItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/OrderItem.php
Normal file
30
app/Models/OrderItem.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class OrderItem extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'order_id',
|
||||||
|
'product_id',
|
||||||
|
'quantity',
|
||||||
|
'unit_price',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'unit_price' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function order(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Order::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function product(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Product::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/Product.php
Normal file
30
app/Models/Product.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Product extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'sku',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'price',
|
||||||
|
'stock_quantity',
|
||||||
|
'category',
|
||||||
|
'status',
|
||||||
|
'imported_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'price' => 'decimal:2',
|
||||||
|
'imported_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function orderItems(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(OrderItem::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Models/User.php
Normal file
48
app/Models/User.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Models/WebhookDelivery.php
Normal file
25
app/Models/WebhookDelivery.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class WebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'workflow_id',
|
||||||
|
'endpoint',
|
||||||
|
'payload',
|
||||||
|
'status',
|
||||||
|
'attempts',
|
||||||
|
'last_error',
|
||||||
|
'delivered_at',
|
||||||
|
'dead_lettered_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'delivered_at' => 'datetime',
|
||||||
|
'dead_lettered_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Providers/TemporalServiceProvider.php
Normal file
29
app/Providers/TemporalServiceProvider.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Temporal\Client\GRPC\ServiceClient;
|
||||||
|
use Temporal\Client\WorkflowClient;
|
||||||
|
use Temporal\Client\WorkflowClientInterface;
|
||||||
|
|
||||||
|
class TemporalServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(WorkflowClientInterface::class, function () {
|
||||||
|
return WorkflowClient::create(
|
||||||
|
ServiceClient::create(config('temporal.address'))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->app->alias(WorkflowClientInterface::class, WorkflowClient::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__ . '/../../config/temporal.php' => config_path('temporal.php'),
|
||||||
|
], 'temporal-config');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Temporal/DataEnrichment/DataEnrichmentActivity.php
Normal file
72
app/Temporal/DataEnrichment/DataEnrichmentActivity.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\DataEnrichment;
|
||||||
|
|
||||||
|
use App\Models\EnrichmentResult;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class DataEnrichmentActivity implements DataEnrichmentActivityInterface
|
||||||
|
{
|
||||||
|
public function geocodeAddress(array $record, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'geocodeAddress');
|
||||||
|
|
||||||
|
// Simulate geocoding API latency
|
||||||
|
usleep(mt_rand(200000, 500000));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lat' => round(mt_rand(2500, 4800) / 100, 6),
|
||||||
|
'lng' => round(mt_rand(-12200, -7100) / 100, 6),
|
||||||
|
'confidence' => round(mt_rand(70, 99) / 100, 2),
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateEmail(array $record, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'validateEmail');
|
||||||
|
|
||||||
|
// Simulate email validation API latency
|
||||||
|
usleep(mt_rand(50000, 150000));
|
||||||
|
|
||||||
|
$email = $record['customer_email'] ?? 'unknown@example.com';
|
||||||
|
$isValid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'valid' => $isValid,
|
||||||
|
'disposable' => mt_rand(1, 100) <= 5,
|
||||||
|
'deliverable' => $isValid && mt_rand(1, 100) <= 95,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function calculateCreditScore(array $record, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'calculateCreditScore');
|
||||||
|
|
||||||
|
// Simulate credit scoring API latency
|
||||||
|
usleep(mt_rand(300000, 600000));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => mt_rand(300, 850),
|
||||||
|
'risk_level' => ['low', 'medium', 'high'][array_rand(['low', 'medium', 'high'])],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveEnrichedRecord(int $recordId, array $enrichments): bool
|
||||||
|
{
|
||||||
|
EnrichmentResult::updateOrCreate(
|
||||||
|
['record_type' => 'order', 'record_id' => $recordId],
|
||||||
|
[
|
||||||
|
'geocode_result' => $enrichments['geocode'] ?? null,
|
||||||
|
'email_valid' => $enrichments['email']['valid'] ?? null,
|
||||||
|
'credit_score' => $enrichments['credit']['score'] ?? null,
|
||||||
|
'enriched_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\DataEnrichment;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface DataEnrichmentActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function geocodeAddress(array $record, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function validateEmail(array $record, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function calculateCreditScore(array $record, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function saveEnrichedRecord(int $recordId, array $enrichments): bool;
|
||||||
|
}
|
||||||
198
app/Temporal/DataEnrichment/DataEnrichmentWorkflow.php
Normal file
198
app/Temporal/DataEnrichment/DataEnrichmentWorkflow.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\DataEnrichment;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class DataEnrichmentWorkflow implements DataEnrichmentWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private int $totalRecords = 0;
|
||||||
|
private int $enrichedRecords = 0;
|
||||||
|
private int $failedRecords = 0;
|
||||||
|
private array $apiStats = [
|
||||||
|
'geocode' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
|
||||||
|
'email' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
|
||||||
|
'credit' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var DataEnrichmentActivityInterface - geocoding: patient retries */
|
||||||
|
private $geocodeStub;
|
||||||
|
|
||||||
|
/** @var DataEnrichmentActivityInterface - email: fast fail */
|
||||||
|
private $emailStub;
|
||||||
|
|
||||||
|
/** @var DataEnrichmentActivityInterface - credit: medium retries */
|
||||||
|
private $creditStub;
|
||||||
|
|
||||||
|
/** @var DataEnrichmentActivityInterface - save: minimal retries */
|
||||||
|
private $saveStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Geocoding: 5 attempts, 3s initial, 1.5 backoff (patient)
|
||||||
|
$this->geocodeStub = Workflow::newActivityStub(
|
||||||
|
DataEnrichmentActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(2))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(5)
|
||||||
|
->withInitialInterval(CarbonInterval::seconds(3))
|
||||||
|
->withBackoffCoefficient(1.5)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Email validation: 2 attempts, 500ms initial, 2.0 backoff (fast fail)
|
||||||
|
$this->emailStub = Workflow::newActivityStub(
|
||||||
|
DataEnrichmentActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::seconds(30))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(2)
|
||||||
|
->withInitialInterval(CarbonInterval::milliseconds(500))
|
||||||
|
->withBackoffCoefficient(2.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Credit scoring: 3 attempts, 2s initial, 2.0 backoff (medium)
|
||||||
|
$this->creditStub = Workflow::newActivityStub(
|
||||||
|
DataEnrichmentActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(1))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(3)
|
||||||
|
->withInitialInterval(CarbonInterval::seconds(2))
|
||||||
|
->withBackoffCoefficient(2.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save: just 1 attempt
|
||||||
|
$this->saveStub = Workflow::newActivityStub(
|
||||||
|
DataEnrichmentActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::seconds(15))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()->withMaximumAttempts(2)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enrich(array $recordIds, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
$this->totalRecords = count($recordIds);
|
||||||
|
$this->status = 'enriching';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$batchSize = 5;
|
||||||
|
$batches = array_chunk($recordIds, $batchSize);
|
||||||
|
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
foreach ($batch as $recordId) {
|
||||||
|
// Run 3 enrichment APIs in parallel per record
|
||||||
|
$geocodePromise = Workflow::async(function () use ($recordId, $simulationConfig) {
|
||||||
|
try {
|
||||||
|
$result = yield $this->geocodeStub->geocodeAddress(
|
||||||
|
['id' => $recordId],
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
return ['success' => true, 'data' => (array) $result];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$emailPromise = Workflow::async(function () use ($recordId, $simulationConfig) {
|
||||||
|
try {
|
||||||
|
$result = yield $this->emailStub->validateEmail(
|
||||||
|
['id' => $recordId, 'customer_email' => "customer{$recordId}@example.com"],
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
return ['success' => true, 'data' => (array) $result];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$creditPromise = Workflow::async(function () use ($recordId, $simulationConfig) {
|
||||||
|
try {
|
||||||
|
$result = yield $this->creditStub->calculateCreditScore(
|
||||||
|
['id' => $recordId],
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
return ['success' => true, 'data' => (array) $result];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all 3 to complete
|
||||||
|
$geocodeResult = (array) (yield $geocodePromise);
|
||||||
|
$emailResult = (array) (yield $emailPromise);
|
||||||
|
$creditResult = (array) (yield $creditPromise);
|
||||||
|
|
||||||
|
// Track per-API stats
|
||||||
|
$this->trackApiStat('geocode', $geocodeResult);
|
||||||
|
$this->trackApiStat('email', $emailResult);
|
||||||
|
$this->trackApiStat('credit', $creditResult);
|
||||||
|
|
||||||
|
// Save enriched data
|
||||||
|
$enrichments = [
|
||||||
|
'geocode' => $geocodeResult['success'] ? $geocodeResult['data'] : null,
|
||||||
|
'email' => $emailResult['success'] ? $emailResult['data'] : null,
|
||||||
|
'credit' => $creditResult['success'] ? $creditResult['data'] : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$anySuccess = $geocodeResult['success'] || $emailResult['success'] || $creditResult['success'];
|
||||||
|
|
||||||
|
if ($anySuccess) {
|
||||||
|
yield $this->saveStub->saveEnrichedRecord($recordId, $enrichments);
|
||||||
|
$this->enrichedRecords++;
|
||||||
|
} else {
|
||||||
|
$this->failedRecords++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'completed';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'completed',
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'enrichedRecords' => $this->enrichedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'apiStats' => $this->apiStats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trackApiStat(string $api, array $result): void
|
||||||
|
{
|
||||||
|
$this->apiStats[$api]['calls']++;
|
||||||
|
if ($result['success'] && isset($result['data']['attempt'])) {
|
||||||
|
$attempt = $result['data']['attempt'];
|
||||||
|
if ($attempt > 1) {
|
||||||
|
$this->apiStats[$api]['retries'] += ($attempt - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'enrichedRecords' => $this->enrichedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'apiStats' => $this->apiStats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\DataEnrichment;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface DataEnrichmentWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function enrich(array $recordIds, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getProgress(): array;
|
||||||
|
}
|
||||||
393
app/Temporal/EloquentQuery/EloquentQueryActivity.php
Normal file
393
app/Temporal/EloquentQuery/EloquentQueryActivity.php
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\EloquentQuery;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class EloquentQueryActivity implements EloquentQueryActivityInterface
|
||||||
|
{
|
||||||
|
public function runInventoryAnalytics(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runInventoryAnalytics');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
|
||||||
|
// Global stats with aggregation functions
|
||||||
|
$globalStats = Product::selectRaw('
|
||||||
|
COUNT(*) as total_products,
|
||||||
|
SUM(stock_quantity) as total_stock,
|
||||||
|
AVG(price) as avg_price,
|
||||||
|
MIN(price) as min_price,
|
||||||
|
MAX(price) as max_price,
|
||||||
|
SUM(price * stock_quantity) as total_inventory_value
|
||||||
|
')->first();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Per-category breakdown
|
||||||
|
$categoryBreakdown = Product::select('category')
|
||||||
|
->selectRaw('COUNT(*) as product_count')
|
||||||
|
->selectRaw('SUM(stock_quantity) as category_stock')
|
||||||
|
->selectRaw('AVG(price) as avg_price')
|
||||||
|
->selectRaw('SUM(price * stock_quantity) as category_value')
|
||||||
|
->groupBy('category')
|
||||||
|
->orderByDesc('category_value')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Stock level distribution using CASE WHEN
|
||||||
|
$stockDistribution = Product::selectRaw("
|
||||||
|
CASE
|
||||||
|
WHEN stock_quantity = 0 THEN 'out_of_stock'
|
||||||
|
WHEN stock_quantity BETWEEN 1 AND 10 THEN 'low_stock'
|
||||||
|
WHEN stock_quantity BETWEEN 11 AND 50 THEN 'medium_stock'
|
||||||
|
ELSE 'high_stock'
|
||||||
|
END as stock_level
|
||||||
|
")
|
||||||
|
->selectRaw('COUNT(*) as count')
|
||||||
|
->groupBy('stock_level')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => 0,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'globalStats' => $globalStats ? $globalStats->toArray() : [],
|
||||||
|
'categoryBreakdown' => $categoryBreakdown,
|
||||||
|
'stockDistribution' => $stockDistribution,
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runOrderDeepLoad(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runOrderDeepLoad');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
|
||||||
|
// Eager load orders with items and products, count items
|
||||||
|
$orders = Order::with(['items.product'])
|
||||||
|
->withCount('items')
|
||||||
|
->get();
|
||||||
|
$queriesRun += 3; // orders + items + products (eager loading)
|
||||||
|
|
||||||
|
// Collection mapping — build summary per order
|
||||||
|
$orderSummaries = $orders->map(function ($order) {
|
||||||
|
$items = $order->items;
|
||||||
|
$heaviest = $items->sortByDesc('quantity')->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $order->id,
|
||||||
|
'order_number' => $order->order_number,
|
||||||
|
'customer_name' => $order->customer_name,
|
||||||
|
'items_count' => $order->items_count,
|
||||||
|
'total_amount' => $order->total_amount,
|
||||||
|
'heaviest_line_item' => $heaviest ? [
|
||||||
|
'product' => $heaviest->product?->name,
|
||||||
|
'quantity' => $heaviest->quantity,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
// Products that have been ordered vs never ordered
|
||||||
|
$orderedProductCount = Product::whereHas('orderItems')->count();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
$neverOrderedCount = Product::whereDoesntHave('orderItems')->count();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => 0,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'orderCount' => $orders->count(),
|
||||||
|
'orderSummaries' => array_slice($orderSummaries, 0, 10),
|
||||||
|
'orderedProductCount' => $orderedProductCount,
|
||||||
|
'neverOrderedCount' => $neverOrderedCount,
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runProductScoring(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runProductScoring');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
|
||||||
|
// Premium products: high price, in stock, ordered by price desc
|
||||||
|
$premiumProducts = Product::where('price', '>', 50)
|
||||||
|
->where('stock_quantity', '>', 0)
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderByDesc('price')
|
||||||
|
->limit(10)
|
||||||
|
->get(['id', 'name', 'price', 'stock_quantity', 'category'])
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// At-risk inventory: nested where with orWhere closures
|
||||||
|
$atRiskProducts = Product::where(function ($q) {
|
||||||
|
$q->where('stock_quantity', 0)
|
||||||
|
->where('status', 'active');
|
||||||
|
})->orWhere(function ($q) {
|
||||||
|
$q->where('stock_quantity', '<', 5)
|
||||||
|
->where('stock_quantity', '>', 0)
|
||||||
|
->whereHas('orderItems');
|
||||||
|
})->get(['id', 'name', 'stock_quantity', 'category', 'status'])
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Category tiers: groupBy with having
|
||||||
|
$categoryTiers = Product::select('category')
|
||||||
|
->selectRaw('COUNT(*) as product_count')
|
||||||
|
->selectRaw('AVG(price) as avg_price')
|
||||||
|
->selectRaw('SUM(stock_quantity) as total_stock')
|
||||||
|
->groupBy('category')
|
||||||
|
->having('avg_price', '>', 50)
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Demand score: leftJoin with computed ratio
|
||||||
|
$demandScores = DB::table('products')
|
||||||
|
->leftJoin('order_items', 'products.id', '=', 'order_items.product_id')
|
||||||
|
->select(
|
||||||
|
'products.id',
|
||||||
|
'products.name',
|
||||||
|
'products.stock_quantity',
|
||||||
|
'products.category'
|
||||||
|
)
|
||||||
|
->selectRaw('COALESCE(SUM(order_items.quantity), 0) as total_ordered')
|
||||||
|
->selectRaw('CASE WHEN products.stock_quantity > 0 THEN ROUND(COALESCE(SUM(order_items.quantity), 0)::numeric / products.stock_quantity, 2) ELSE 0 END as demand_ratio')
|
||||||
|
->groupBy('products.id', 'products.name', 'products.stock_quantity', 'products.category')
|
||||||
|
->orderByDesc('demand_ratio')
|
||||||
|
->limit(15)
|
||||||
|
->get()
|
||||||
|
->map(fn ($r) => (array) $r)
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => 0,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'premiumProducts' => $premiumProducts,
|
||||||
|
'atRiskProducts' => array_slice($atRiskProducts, 0, 10),
|
||||||
|
'categoryTiers' => $categoryTiers,
|
||||||
|
'demandScores' => $demandScores,
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runStockAudit(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runStockAudit');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
$rowsAffected = 0;
|
||||||
|
$negativeFixed = 0;
|
||||||
|
$timestamped = 0;
|
||||||
|
|
||||||
|
Product::orderBy('id')->chunk(100, function ($products) use (&$queriesRun, &$rowsAffected, &$negativeFixed, &$timestamped) {
|
||||||
|
$queriesRun++; // each chunk is a query
|
||||||
|
|
||||||
|
Activity::heartbeat([
|
||||||
|
'processed' => $queriesRun * 100,
|
||||||
|
'negativeFixed' => $negativeFixed,
|
||||||
|
'timestamped' => $timestamped,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$changed = false;
|
||||||
|
$updates = [];
|
||||||
|
|
||||||
|
// Fix negative stock
|
||||||
|
if ($product->stock_quantity < 0) {
|
||||||
|
$updates['stock_quantity'] = 0;
|
||||||
|
$negativeFixed++;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp missing imported_at
|
||||||
|
if ($product->imported_at === null) {
|
||||||
|
$updates['imported_at'] = now();
|
||||||
|
$timestamped++;
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
$product->update($updates);
|
||||||
|
$rowsAffected++;
|
||||||
|
$queriesRun++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => $rowsAffected,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'negativeStockFixed' => $negativeFixed,
|
||||||
|
'missingTimestampFixed' => $timestamped,
|
||||||
|
'note' => 'Idempotent — subsequent runs make 0 changes if data is clean',
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runPriceRecalculation(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runPriceRecalculation');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
$rowsAffected = 0;
|
||||||
|
$discrepancies = [];
|
||||||
|
|
||||||
|
// Recalculate each order total from its items within a transaction
|
||||||
|
$orderIds = Order::pluck('id')->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
foreach ($orderIds as $orderId) {
|
||||||
|
DB::transaction(function () use ($orderId, &$queriesRun, &$rowsAffected, &$discrepancies) {
|
||||||
|
$order = Order::lockForUpdate()->find($orderId);
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recalculated = OrderItem::where('order_id', $orderId)
|
||||||
|
->selectRaw('SUM(quantity * unit_price) as computed_total')
|
||||||
|
->value('computed_total') ?? 0;
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
$oldTotal = (float) $order->total_amount;
|
||||||
|
$newTotal = round((float) $recalculated, 2);
|
||||||
|
|
||||||
|
if (abs($oldTotal - $newTotal) > 0.01) {
|
||||||
|
$discrepancies[] = [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'old_total' => $oldTotal,
|
||||||
|
'new_total' => $newTotal,
|
||||||
|
'diff' => round($newTotal - $oldTotal, 2),
|
||||||
|
];
|
||||||
|
$order->update(['total_amount' => $newTotal]);
|
||||||
|
$rowsAffected++;
|
||||||
|
$queriesRun++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demonstrate updateOrCreate (upsert) pattern
|
||||||
|
Product::updateOrCreate(
|
||||||
|
['sku' => '_RECONCILIATION_MARKER'],
|
||||||
|
[
|
||||||
|
'name' => 'Reconciliation Marker',
|
||||||
|
'price' => 0,
|
||||||
|
'stock_quantity' => count($orderIds),
|
||||||
|
'category' => 'system',
|
||||||
|
'status' => 'inactive',
|
||||||
|
'description' => 'Last reconciliation: ' . now()->toISOString(),
|
||||||
|
'imported_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$queriesRun++;
|
||||||
|
$rowsAffected++;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => $rowsAffected,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'ordersChecked' => count($orderIds),
|
||||||
|
'discrepancies' => $discrepancies,
|
||||||
|
'discrepancyCount' => count($discrepancies),
|
||||||
|
'reconciliationMarkerSet' => true,
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runSummaryReport(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runSummaryReport');
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$queriesRun = 0;
|
||||||
|
|
||||||
|
// Top-selling products via DB::table join
|
||||||
|
$topSelling = DB::table('order_items')
|
||||||
|
->select('products.id', 'products.name', 'products.category')
|
||||||
|
->selectRaw('SUM(order_items.quantity) as total_sold')
|
||||||
|
->selectRaw('SUM(order_items.quantity * order_items.unit_price) as total_revenue')
|
||||||
|
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||||
|
->groupBy('products.id', 'products.name', 'products.category')
|
||||||
|
->orderByDesc('total_revenue')
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->map(fn ($r) => (array) $r)
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Revenue per category
|
||||||
|
$revenueByCategory = DB::table('order_items')
|
||||||
|
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||||
|
->select('products.category')
|
||||||
|
->selectRaw('SUM(order_items.quantity * order_items.unit_price) as category_revenue')
|
||||||
|
->selectRaw('COUNT(DISTINCT order_items.order_id) as order_count')
|
||||||
|
->groupBy('products.category')
|
||||||
|
->orderByDesc('category_revenue')
|
||||||
|
->get()
|
||||||
|
->map(fn ($r) => (array) $r)
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Customer spend ranking
|
||||||
|
$customerRanking = Order::select('customer_name')
|
||||||
|
->selectRaw('COUNT(*) as order_count')
|
||||||
|
->selectRaw('SUM(total_amount) as total_spent')
|
||||||
|
->selectRaw('AVG(total_amount) as avg_order_value')
|
||||||
|
->groupBy('customer_name')
|
||||||
|
->orderByDesc('total_spent')
|
||||||
|
->limit(10)
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
$queriesRun++;
|
||||||
|
|
||||||
|
// Cross-model health summary
|
||||||
|
$healthSummary = [
|
||||||
|
'products' => Product::count(),
|
||||||
|
'active_products' => Product::where('status', 'active')->count(),
|
||||||
|
'orders' => Order::count(),
|
||||||
|
'order_items' => OrderItem::count(),
|
||||||
|
'users' => User::count(),
|
||||||
|
];
|
||||||
|
$queriesRun += 5;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queriesRun' => $queriesRun,
|
||||||
|
'rowsAffected' => 0,
|
||||||
|
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
|
||||||
|
'data' => [
|
||||||
|
'topSelling' => $topSelling,
|
||||||
|
'revenueByCategory' => $revenueByCategory,
|
||||||
|
'customerRanking' => $customerRanking,
|
||||||
|
'healthSummary' => $healthSummary,
|
||||||
|
],
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\EloquentQuery;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface EloquentQueryActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runInventoryAnalytics(array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runOrderDeepLoad(array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runProductScoring(array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runStockAudit(array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runPriceRecalculation(array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runSummaryReport(array $simulationConfig = []): array;
|
||||||
|
}
|
||||||
100
app/Temporal/EloquentQuery/EloquentQueryWorkflow.php
Normal file
100
app/Temporal/EloquentQuery/EloquentQueryWorkflow.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\EloquentQuery;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class EloquentQueryWorkflow implements EloquentQueryWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private string $currentStep = '';
|
||||||
|
private int $completedSteps = 0;
|
||||||
|
private array $stepResults = [];
|
||||||
|
private int $totalQueriesRun = 0;
|
||||||
|
private int $totalRowsAffected = 0;
|
||||||
|
private float $totalExecutionTimeMs = 0;
|
||||||
|
|
||||||
|
/** @var EloquentQueryActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
private const STEPS = [
|
||||||
|
'runInventoryAnalytics' => 'Inventory Analytics',
|
||||||
|
'runOrderDeepLoad' => 'Order Deep Load',
|
||||||
|
'runProductScoring' => 'Product Scoring',
|
||||||
|
'runStockAudit' => 'Stock Audit',
|
||||||
|
'runPriceRecalculation' => 'Price Recalculation',
|
||||||
|
'runSummaryReport' => 'Summary Report',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
EloquentQueryActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(2))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(30))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()->withMaximumAttempts(3)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function runPipeline(array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
$this->status = 'running';
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach (self::STEPS as $method => $label) {
|
||||||
|
$this->currentStep = $label;
|
||||||
|
|
||||||
|
$result = yield $this->activityStub->$method($simulationConfig);
|
||||||
|
|
||||||
|
$this->stepResults[$method] = [
|
||||||
|
'label' => $label,
|
||||||
|
'queriesRun' => $result['queriesRun'] ?? 0,
|
||||||
|
'rowsAffected' => $result['rowsAffected'] ?? 0,
|
||||||
|
'executionTimeMs' => $result['executionTimeMs'] ?? 0,
|
||||||
|
'data' => $result['data'] ?? [],
|
||||||
|
'attempt' => $result['attempt'] ?? 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->totalQueriesRun += $result['queriesRun'] ?? 0;
|
||||||
|
$this->totalRowsAffected += $result['rowsAffected'] ?? 0;
|
||||||
|
$this->totalExecutionTimeMs += $result['executionTimeMs'] ?? 0;
|
||||||
|
$this->completedSteps++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'completed';
|
||||||
|
$this->currentStep = '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'completed',
|
||||||
|
'completedSteps' => $this->completedSteps,
|
||||||
|
'totalQueriesRun' => $this->totalQueriesRun,
|
||||||
|
'totalRowsAffected' => $this->totalRowsAffected,
|
||||||
|
'totalExecutionTimeMs' => $this->totalExecutionTimeMs,
|
||||||
|
'stepResults' => $this->stepResults,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'currentStep' => $this->currentStep,
|
||||||
|
'completedSteps' => $this->completedSteps,
|
||||||
|
'totalSteps' => count(self::STEPS),
|
||||||
|
'stepResults' => $this->stepResults,
|
||||||
|
'totalQueriesRun' => $this->totalQueriesRun,
|
||||||
|
'totalRowsAffected' => $this->totalRowsAffected,
|
||||||
|
'totalExecutionTimeMs' => $this->totalExecutionTimeMs,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\EloquentQuery;
|
||||||
|
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface EloquentQueryWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function runPipeline(array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getProgress(): array;
|
||||||
|
}
|
||||||
94
app/Temporal/ExternalApiSync/ExternalApiSyncActivity.php
Normal file
94
app/Temporal/ExternalApiSync/ExternalApiSyncActivity.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ExternalApiSync;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class ExternalApiSyncActivity implements ExternalApiSyncActivityInterface
|
||||||
|
{
|
||||||
|
private const PAGE_SIZE = 10;
|
||||||
|
private const TOTAL_SIMULATED_RECORDS = 50;
|
||||||
|
|
||||||
|
public function refreshToken(array $simulationConfig = []): string
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'refreshToken');
|
||||||
|
|
||||||
|
// Simulate OAuth token refresh
|
||||||
|
usleep(200000);
|
||||||
|
|
||||||
|
return 'tok_' . Str::random(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchPage(string $cursor, string $token, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'fetchPage');
|
||||||
|
|
||||||
|
Activity::heartbeat(['cursor' => $cursor, 'token_prefix' => substr($token, 0, 8)]);
|
||||||
|
|
||||||
|
// Simulate API latency
|
||||||
|
usleep(150000);
|
||||||
|
|
||||||
|
$offset = (int) $cursor;
|
||||||
|
$records = [];
|
||||||
|
|
||||||
|
for ($i = $offset; $i < min($offset + self::PAGE_SIZE, self::TOTAL_SIMULATED_RECORDS); $i++) {
|
||||||
|
$records[] = [
|
||||||
|
'external_id' => 'EXT-' . str_pad((string) ($i + 1), 5, '0', STR_PAD_LEFT),
|
||||||
|
'name' => 'Synced Product ' . ($i + 1),
|
||||||
|
'description' => 'Product synced from external API',
|
||||||
|
'price' => round(mt_rand(500, 50000) / 100, 2),
|
||||||
|
'stock' => mt_rand(10, 500),
|
||||||
|
'category' => ['Electronics', 'Clothing', 'Home & Garden', 'Sports'][array_rand(['Electronics', 'Clothing', 'Home & Garden', 'Sports'])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextOffset = $offset + self::PAGE_SIZE;
|
||||||
|
$hasMore = $nextOffset < self::TOTAL_SIMULATED_RECORDS;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'records' => $records,
|
||||||
|
'nextCursor' => (string) $nextOffset,
|
||||||
|
'hasMore' => $hasMore,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transformAndStore(array $records, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'transformAndStore');
|
||||||
|
|
||||||
|
$stored = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$sku = 'SYNC-' . $record['external_id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
Product::updateOrCreate(
|
||||||
|
['sku' => $sku],
|
||||||
|
[
|
||||||
|
'name' => $record['name'],
|
||||||
|
'description' => $record['description'],
|
||||||
|
'price' => $record['price'],
|
||||||
|
'stock_quantity' => $record['stock'],
|
||||||
|
'category' => $record['category'],
|
||||||
|
'status' => 'active',
|
||||||
|
'imported_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
$stored++;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'stored' => $stored,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ExternalApiSync;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface ExternalApiSyncActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function refreshToken(array $simulationConfig = []): string;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function fetchPage(string $cursor, string $token, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function transformAndStore(array $records, array $simulationConfig = []): array;
|
||||||
|
}
|
||||||
123
app/Temporal/ExternalApiSync/ExternalApiSyncWorkflow.php
Normal file
123
app/Temporal/ExternalApiSync/ExternalApiSyncWorkflow.php
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ExternalApiSync;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class ExternalApiSyncWorkflow implements ExternalApiSyncWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private int $pagesFetched = 0;
|
||||||
|
private int $recordsSynced = 0;
|
||||||
|
private int $rateLimitHits = 0;
|
||||||
|
private int $retryCount = 0;
|
||||||
|
private string $currentCursor = '0';
|
||||||
|
private bool $isPaused = false;
|
||||||
|
|
||||||
|
/** @var ExternalApiSyncActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
ExternalApiSyncActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(5))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(30))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(5)
|
||||||
|
->withInitialInterval(CarbonInterval::seconds(2))
|
||||||
|
->withBackoffCoefficient(2.0)
|
||||||
|
->withMaximumInterval(CarbonInterval::seconds(30))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sync(string $apiEndpoint, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Step 1: Refresh API token
|
||||||
|
$this->status = 'authenticating';
|
||||||
|
$token = yield $this->activityStub->refreshToken($simulationConfig);
|
||||||
|
|
||||||
|
// Step 2: Paginated fetch loop
|
||||||
|
$this->status = 'syncing';
|
||||||
|
$hasMore = true;
|
||||||
|
|
||||||
|
while ($hasMore) {
|
||||||
|
// Check for pause
|
||||||
|
if ($this->isPaused) {
|
||||||
|
$this->status = 'paused';
|
||||||
|
yield Workflow::await(fn () => !$this->isPaused);
|
||||||
|
$this->status = 'syncing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch one page
|
||||||
|
$pageResult = yield $this->activityStub->fetchPage($this->currentCursor, $token, $simulationConfig);
|
||||||
|
$pageResult = (array) $pageResult;
|
||||||
|
|
||||||
|
$attempt = $pageResult['attempt'] ?? 1;
|
||||||
|
if ($attempt > 1) {
|
||||||
|
$this->retryCount += ($attempt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pagesFetched++;
|
||||||
|
$this->currentCursor = $pageResult['nextCursor'];
|
||||||
|
$hasMore = $pageResult['hasMore'];
|
||||||
|
|
||||||
|
// Transform and store records
|
||||||
|
if (!empty($pageResult['records'])) {
|
||||||
|
$storeResult = yield $this->activityStub->transformAndStore($pageResult['records'], $simulationConfig);
|
||||||
|
$storeResult = (array) $storeResult;
|
||||||
|
|
||||||
|
$this->recordsSynced += $storeResult['stored'] ?? 0;
|
||||||
|
|
||||||
|
$storeAttempt = $storeResult['attempt'] ?? 1;
|
||||||
|
if ($storeAttempt > 1) {
|
||||||
|
$this->retryCount += ($storeAttempt - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'completed';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'completed',
|
||||||
|
'pagesFetched' => $this->pagesFetched,
|
||||||
|
'recordsSynced' => $this->recordsSynced,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pause(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'pagesFetched' => $this->pagesFetched,
|
||||||
|
'recordsSynced' => $this->recordsSynced,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'currentCursor' => $this->currentCursor,
|
||||||
|
'isPaused' => $this->isPaused,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ExternalApiSync;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\SignalMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface ExternalApiSyncWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function sync(string $apiEndpoint, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function pause(): void;
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function resume(): void;
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getProgress(): array;
|
||||||
|
}
|
||||||
184
app/Temporal/OrderFulfillment/OrderActivity.php
Normal file
184
app/Temporal/OrderFulfillment/OrderActivity.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\OrderFulfillment;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class OrderActivity implements OrderActivityInterface
|
||||||
|
{
|
||||||
|
public function validateOrder(int $orderId, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'validateOrder');
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($order->status !== 'pending') {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} is not in 'pending' status. Current status: {$order->status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("Order #{$orderId} validated successfully.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkInventory(int $orderId, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'checkInventory');
|
||||||
|
|
||||||
|
$order = Order::with('items.product')->find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
if ($item->product->stock_quantity < $item->quantity) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Insufficient stock for product '{$item->product->name}'. "
|
||||||
|
. "Available: {$item->product->stock_quantity}, Requested: {$item->quantity}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("Inventory check passed for order #{$orderId}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processPayment(int $orderId, array $simulationConfig = []): string
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'processPayment');
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentId = 'PAY-' . strtoupper(substr(md5(uniqid((string) $orderId, true)), 0, 12));
|
||||||
|
|
||||||
|
$order->update([
|
||||||
|
'status' => 'processing',
|
||||||
|
'payment_id' => $paymentId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info("Payment processed for order #{$orderId}. Payment ID: {$paymentId}");
|
||||||
|
|
||||||
|
return $paymentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refundPayment(int $orderId, string $paymentId): bool
|
||||||
|
{
|
||||||
|
usleep(300000);
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if ($order) {
|
||||||
|
$order->update([
|
||||||
|
'status' => 'refunded',
|
||||||
|
'payment_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning("Payment refunded for order #{$orderId}. Payment ID: {$paymentId}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateInventory(int $orderId, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'updateInventory');
|
||||||
|
|
||||||
|
$order = Order::with('items.product')->find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$item->product->decrement('stock_quantity', $item->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("Inventory updated (decremented) for order #{$orderId}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreInventory(int $orderId): bool
|
||||||
|
{
|
||||||
|
usleep(200000);
|
||||||
|
|
||||||
|
$order = Order::with('items.product')->find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($order->items as $item) {
|
||||||
|
$item->product->increment('stock_quantity', $item->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info("Inventory restored (incremented) for order #{$orderId}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifyWarehouse(int $orderId, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'notifyWarehouse');
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update(['status' => 'warehouse_notified']);
|
||||||
|
|
||||||
|
Log::info("Warehouse notified for order #{$orderId}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelWarehouseNotification(int $orderId): bool
|
||||||
|
{
|
||||||
|
usleep(100000);
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if ($order) {
|
||||||
|
$order->update(['status' => 'warehouse_cancelled']);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning("Warehouse notification cancelled for order #{$orderId}.");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTrackingInfo(int $orderId, string $trackingNumber, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'sendTrackingInfo');
|
||||||
|
|
||||||
|
$order = Order::find($orderId);
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
throw new \RuntimeException("Order #{$orderId} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update([
|
||||||
|
'tracking_number' => $trackingNumber,
|
||||||
|
'status' => 'shipped',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info("Tracking info sent for order #{$orderId}. Tracking: {$trackingNumber}");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Temporal/OrderFulfillment/OrderActivityInterface.php
Normal file
37
app/Temporal/OrderFulfillment/OrderActivityInterface.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\OrderFulfillment;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface OrderActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function validateOrder(int $orderId, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function checkInventory(int $orderId, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function processPayment(int $orderId, array $simulationConfig = []): string;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function refundPayment(int $orderId, string $paymentId): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function updateInventory(int $orderId, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function restoreInventory(int $orderId): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function notifyWarehouse(int $orderId, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function cancelWarehouseNotification(int $orderId): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function sendTrackingInfo(int $orderId, string $trackingNumber, array $simulationConfig = []): bool;
|
||||||
|
}
|
||||||
114
app/Temporal/OrderFulfillment/OrderFulfillmentWorkflow.php
Normal file
114
app/Temporal/OrderFulfillment/OrderFulfillmentWorkflow.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\OrderFulfillment;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class OrderFulfillmentWorkflow implements OrderFulfillmentWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private ?string $trackingNumber = null;
|
||||||
|
private bool $shippingConfirmed = false;
|
||||||
|
private int $orderId = 0;
|
||||||
|
private int $retryCount = 0;
|
||||||
|
private int $rateLimitHits = 0;
|
||||||
|
|
||||||
|
/** @var mixed Activity stub - untyped due to PHP SDK limitation */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
OrderActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(5))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(3)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processOrder(int $orderId, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
$this->orderId = $orderId;
|
||||||
|
|
||||||
|
$saga = new Workflow\Saga();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Validate the order
|
||||||
|
$this->status = 'validating';
|
||||||
|
yield $this->activityStub->validateOrder($orderId, $simulationConfig);
|
||||||
|
|
||||||
|
// Step 2: Check inventory availability
|
||||||
|
$this->status = 'checking_inventory';
|
||||||
|
yield $this->activityStub->checkInventory($orderId, $simulationConfig);
|
||||||
|
|
||||||
|
// Step 3: Process payment
|
||||||
|
$this->status = 'processing_payment';
|
||||||
|
$paymentId = yield $this->activityStub->processPayment($orderId, $simulationConfig);
|
||||||
|
|
||||||
|
// Register compensation: refund payment if later steps fail
|
||||||
|
$saga->addCompensation(fn() => yield $this->activityStub->refundPayment($orderId, $paymentId));
|
||||||
|
|
||||||
|
// Step 4: Update inventory (decrement stock)
|
||||||
|
$this->status = 'updating_inventory';
|
||||||
|
yield $this->activityStub->updateInventory($orderId, $simulationConfig);
|
||||||
|
|
||||||
|
// Register compensation: restore inventory if later steps fail
|
||||||
|
$saga->addCompensation(fn() => yield $this->activityStub->restoreInventory($orderId));
|
||||||
|
|
||||||
|
// Step 5: Notify warehouse
|
||||||
|
$this->status = 'notifying_warehouse';
|
||||||
|
yield $this->activityStub->notifyWarehouse($orderId, $simulationConfig);
|
||||||
|
|
||||||
|
// Register compensation: cancel warehouse notification if later steps fail
|
||||||
|
$saga->addCompensation(fn() => yield $this->activityStub->cancelWarehouseNotification($orderId));
|
||||||
|
|
||||||
|
// Step 6: Wait for shipping confirmation signal
|
||||||
|
$this->status = 'awaiting_shipment';
|
||||||
|
yield Workflow::await(fn() => $this->shippingConfirmed);
|
||||||
|
|
||||||
|
// Step 7: Send tracking information
|
||||||
|
$this->status = 'sending_tracking';
|
||||||
|
yield $this->activityStub->sendTrackingInfo($orderId, $this->trackingNumber, $simulationConfig);
|
||||||
|
|
||||||
|
// All steps completed successfully
|
||||||
|
$this->status = 'completed';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'orderId' => $orderId,
|
||||||
|
'trackingNumber' => $this->trackingNumber,
|
||||||
|
'status' => $this->status,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
yield $saga->compensate();
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmShipping(string $trackingNumber): void
|
||||||
|
{
|
||||||
|
$this->trackingNumber = $trackingNumber;
|
||||||
|
$this->shippingConfirmed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderStatus(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'orderId' => $this->orderId,
|
||||||
|
'trackingNumber' => $this->trackingNumber,
|
||||||
|
'shippingConfirmed' => $this->shippingConfirmed,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\OrderFulfillment;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\SignalMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface OrderFulfillmentWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function processOrder(int $orderId, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function confirmShipping(string $trackingNumber): void;
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getOrderStatus(): array;
|
||||||
|
}
|
||||||
35
app/Temporal/ProductImport/BatchImportWorkflow.php
Normal file
35
app/Temporal/ProductImport/BatchImportWorkflow.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class BatchImportWorkflow implements BatchImportWorkflowInterface
|
||||||
|
{
|
||||||
|
/** @var ProductImportActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
ProductImportActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(5))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(30))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(3)
|
||||||
|
->withInitialInterval(CarbonInterval::seconds(5))
|
||||||
|
->withBackoffCoefficient(2.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
return yield $this->activityStub->processBatch($filePath, $batchNumber, $batchSize, $simulationConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Temporal/ProductImport/BatchImportWorkflowInterface.php
Normal file
13
app/Temporal/ProductImport/BatchImportWorkflowInterface.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface BatchImportWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []);
|
||||||
|
}
|
||||||
147
app/Temporal/ProductImport/ProductImportActivity.php
Normal file
147
app/Temporal/ProductImport/ProductImportActivity.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Temporal\Activity;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class ProductImportActivity implements ProductImportActivityInterface
|
||||||
|
{
|
||||||
|
public function validateFile(string $filePath, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'validateFile');
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw new \RuntimeException("File not found: {$filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_readable($filePath)) {
|
||||||
|
throw new \RuntimeException("File is not readable: {$filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parseAndCountRecords(string $filePath, array $simulationConfig = []): int
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'parseAndCountRecords');
|
||||||
|
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException("Unable to open file: {$filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header row
|
||||||
|
fgetcsv($handle);
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
while (fgetcsv($handle) !== false) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'processBatch');
|
||||||
|
|
||||||
|
$handle = fopen($filePath, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
throw new \RuntimeException("Unable to open file: {$filePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header row
|
||||||
|
fgetcsv($handle);
|
||||||
|
|
||||||
|
// Skip to the batch offset
|
||||||
|
$offset = $batchNumber * $batchSize;
|
||||||
|
for ($i = 0; $i < $offset; $i++) {
|
||||||
|
fgetcsv($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $batchSize; $i++) {
|
||||||
|
$row = fgetcsv($handle);
|
||||||
|
|
||||||
|
if ($row === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat with progress
|
||||||
|
Activity::heartbeat([
|
||||||
|
'batchNumber' => $batchNumber,
|
||||||
|
'current' => $i + 1,
|
||||||
|
'total' => $batchSize,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse CSV columns: sku, name, description, price, stock_quantity, category
|
||||||
|
[$sku, $name, $description, $price, $stockQuantity, $category] = $row;
|
||||||
|
|
||||||
|
Product::updateOrCreate(
|
||||||
|
['sku' => $sku],
|
||||||
|
[
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $description,
|
||||||
|
'price' => (float) $price,
|
||||||
|
'stock_quantity' => (int) $stockQuantity,
|
||||||
|
'category' => $category,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = [
|
||||||
|
'row' => $offset + $i + 1,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
|
||||||
|
Log::warning("Product import batch {$batchNumber} row error", [
|
||||||
|
'row' => $offset + $i + 1,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => $processed,
|
||||||
|
'failed' => $failed,
|
||||||
|
'errors' => $errors,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateReport(
|
||||||
|
string $filePath,
|
||||||
|
int $totalRecords,
|
||||||
|
int $processedRecords,
|
||||||
|
int $failedRecords,
|
||||||
|
array $simulationConfig = []
|
||||||
|
): array {
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'generateReport');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filePath' => $filePath,
|
||||||
|
'totalRecords' => $totalRecords,
|
||||||
|
'processedRecords' => $processedRecords,
|
||||||
|
'failedRecords' => $failedRecords,
|
||||||
|
'successRate' => $totalRecords > 0
|
||||||
|
? round(($processedRecords / $totalRecords) * 100, 2)
|
||||||
|
: 0,
|
||||||
|
'completedAt' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface ProductImportActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function validateFile(string $filePath, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function parseAndCountRecords(string $filePath, array $simulationConfig = []): int;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function generateReport(string $filePath, int $totalRecords, int $processedRecords, int $failedRecords, array $simulationConfig = []): array;
|
||||||
|
}
|
||||||
161
app/Temporal/ProductImport/ProductImportWorkflow.php
Normal file
161
app/Temporal/ProductImport/ProductImportWorkflow.php
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
use Temporal\Workflow\ChildWorkflowOptions;
|
||||||
|
|
||||||
|
class ProductImportWorkflow implements ProductImportWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private int $processedRecords = 0;
|
||||||
|
private int $failedRecords = 0;
|
||||||
|
private int $totalRecords = 0;
|
||||||
|
private bool $isPaused = false;
|
||||||
|
private bool $isCancelled = false;
|
||||||
|
private int $retryCount = 0;
|
||||||
|
private int $rateLimitHits = 0;
|
||||||
|
|
||||||
|
/** @var ProductImportActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
ProductImportActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(5))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(30))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function import(string $filePath, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Step 1: Validate the file
|
||||||
|
$this->status = 'validating';
|
||||||
|
yield $this->activityStub->validateFile($filePath, $simulationConfig);
|
||||||
|
|
||||||
|
// Step 2: Count records
|
||||||
|
$this->status = 'counting';
|
||||||
|
$this->totalRecords = yield $this->activityStub->parseAndCountRecords($filePath, $simulationConfig);
|
||||||
|
|
||||||
|
// Step 3: Process in batches
|
||||||
|
$this->status = 'processing';
|
||||||
|
$batchSize = 50;
|
||||||
|
$batchCount = (int) ceil($this->totalRecords / $batchSize);
|
||||||
|
|
||||||
|
$childPromises = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $batchCount; $i++) {
|
||||||
|
if ($this->isCancelled) {
|
||||||
|
$this->status = 'cancelled';
|
||||||
|
return [
|
||||||
|
'status' => 'cancelled',
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'processedRecords' => $this->processedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isPaused) {
|
||||||
|
$this->status = 'paused';
|
||||||
|
yield Workflow::await(fn () => !$this->isPaused || $this->isCancelled);
|
||||||
|
|
||||||
|
if ($this->isCancelled) {
|
||||||
|
$this->status = 'cancelled';
|
||||||
|
return [
|
||||||
|
'status' => 'cancelled',
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'processedRecords' => $this->processedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn child workflow for this batch
|
||||||
|
$childStub = Workflow::newChildWorkflowStub(
|
||||||
|
BatchImportWorkflowInterface::class,
|
||||||
|
ChildWorkflowOptions::new()
|
||||||
|
);
|
||||||
|
|
||||||
|
$childPromises[] = $childStub->processBatch($filePath, $i, $batchSize, $simulationConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results from all child workflows
|
||||||
|
foreach ($childPromises as $promise) {
|
||||||
|
$result = (array) (yield $promise);
|
||||||
|
$this->processedRecords += $result['processed'] ?? 0;
|
||||||
|
$this->failedRecords += $result['failed'] ?? 0;
|
||||||
|
$attempt = $result['attempt'] ?? 1;
|
||||||
|
if ($attempt > 1) {
|
||||||
|
$this->retryCount += ($attempt - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Generate report
|
||||||
|
$this->status = 'reporting';
|
||||||
|
$report = yield $this->activityStub->generateReport(
|
||||||
|
$filePath,
|
||||||
|
$this->totalRecords,
|
||||||
|
$this->processedRecords,
|
||||||
|
$this->failedRecords,
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Complete
|
||||||
|
$this->status = 'completed';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'completed',
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'processedRecords' => $this->processedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
'report' => $report,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pause(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): void
|
||||||
|
{
|
||||||
|
$this->isCancelled = true;
|
||||||
|
$this->isPaused = false; // Meh, unblock any paused await
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'totalRecords' => $this->totalRecords,
|
||||||
|
'processedRecords' => $this->processedRecords,
|
||||||
|
'failedRecords' => $this->failedRecords,
|
||||||
|
'isPaused' => $this->isPaused,
|
||||||
|
'isCancelled' => $this->isCancelled,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\ProductImport;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\SignalMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface ProductImportWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function import(string $filePath, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function pause(): void;
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function resume(): void;
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function cancel(): void;
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getStatus(): array;
|
||||||
|
}
|
||||||
45
app/Temporal/Shared/FaultSimulator.php
Normal file
45
app/Temporal/Shared/FaultSimulator.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\Shared;
|
||||||
|
|
||||||
|
use Temporal\Exception\Failure\ApplicationFailure;
|
||||||
|
|
||||||
|
class FaultSimulator
|
||||||
|
{
|
||||||
|
public static function maybeApply(array $config, string $activityName = ''): void
|
||||||
|
{
|
||||||
|
if (empty($config)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Base latency
|
||||||
|
$latencyMs = $config['latencyMs'] ?? 0;
|
||||||
|
if ($latencyMs > 0) {
|
||||||
|
usleep($latencyMs * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rate limiting — throw ApplicationFailure with nextRetryDelay
|
||||||
|
$rl = $config['rateLimiting'] ?? [];
|
||||||
|
if (!empty($rl['enabled']) && ($rl['hitChance'] ?? 0) > 0) {
|
||||||
|
if (rand(1, 100) <= $rl['hitChance']) {
|
||||||
|
$retryAfterMs = $rl['retryAfterMs'] ?? 2000;
|
||||||
|
$retrySeconds = (int) ceil($retryAfterMs / 1000);
|
||||||
|
|
||||||
|
throw new ApplicationFailure(
|
||||||
|
"Rate limited (429) on {$activityName}",
|
||||||
|
'RateLimited',
|
||||||
|
false, // nonRetryable = false → Temporal WILL retry
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
\DateInterval::createFromDateString("{$retrySeconds} seconds"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Random failure — plain RuntimeException (Temporal retries automatically)
|
||||||
|
$failureRate = $config['failureRate'] ?? 0;
|
||||||
|
if ($failureRate > 0 && rand(1, 100) <= $failureRate) {
|
||||||
|
throw new \RuntimeException("Simulated failure on {$activityName}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Temporal/SystemMonitor/SystemMonitorActivity.php
Normal file
84
app/Temporal/SystemMonitor/SystemMonitorActivity.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\SystemMonitor;
|
||||||
|
|
||||||
|
use App\Models\ImportJob;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class SystemMonitorActivity implements SystemMonitorActivityInterface
|
||||||
|
{
|
||||||
|
public function runHealthCheck(array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'runHealthCheck');
|
||||||
|
|
||||||
|
$checks = [];
|
||||||
|
$issues = 0;
|
||||||
|
|
||||||
|
// 1. DB connectivity
|
||||||
|
try {
|
||||||
|
DB::select('SELECT 1');
|
||||||
|
$checks['db_connected'] = true;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$checks['db_connected'] = false;
|
||||||
|
$issues += 3; // critical
|
||||||
|
}
|
||||||
|
|
||||||
|
Activity::heartbeat(['step' => 'db_connectivity']);
|
||||||
|
|
||||||
|
// 2. Table row counts
|
||||||
|
$checks['product_count'] = Product::count();
|
||||||
|
$checks['order_count'] = Order::count();
|
||||||
|
$checks['order_item_count'] = OrderItem::count();
|
||||||
|
$checks['user_count'] = User::count();
|
||||||
|
$checks['import_job_count'] = ImportJob::count();
|
||||||
|
|
||||||
|
Activity::heartbeat(['step' => 'row_counts']);
|
||||||
|
|
||||||
|
// 3. Stale data check — pending orders older than 24h
|
||||||
|
$checks['pending_stale_orders'] = Order::where('status', 'pending')
|
||||||
|
->where('created_at', '<', now()->subHours(24))
|
||||||
|
->count();
|
||||||
|
if ($checks['pending_stale_orders'] > 0) {
|
||||||
|
$issues++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Activity::heartbeat(['step' => 'stale_data']);
|
||||||
|
|
||||||
|
// 4. Stock alerts — active products with zero stock
|
||||||
|
$checks['out_of_stock_products'] = Product::where('stock_quantity', 0)
|
||||||
|
->where('status', 'active')
|
||||||
|
->count();
|
||||||
|
if ($checks['out_of_stock_products'] > 5) {
|
||||||
|
$issues++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Activity::heartbeat(['step' => 'stock_alerts']);
|
||||||
|
|
||||||
|
// 5. Stuck import jobs — started but not updated in 30 min
|
||||||
|
$checks['stuck_import_jobs'] = ImportJob::where('status', 'started')
|
||||||
|
->where('updated_at', '<', now()->subMinutes(30))
|
||||||
|
->count();
|
||||||
|
if ($checks['stuck_import_jobs'] > 0) {
|
||||||
|
$issues++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Activity::heartbeat(['step' => 'stuck_jobs']);
|
||||||
|
|
||||||
|
// Compute health score (100 = perfect, deduct per issue)
|
||||||
|
$healthScore = max(0, 100 - ($issues * 10));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'timestamp' => now()->toISOString(),
|
||||||
|
'checks' => $checks,
|
||||||
|
'healthScore' => $healthScore,
|
||||||
|
'issues' => $issues,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\SystemMonitor;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface SystemMonitorActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function runHealthCheck(array $simulationConfig = []): array;
|
||||||
|
}
|
||||||
129
app/Temporal/SystemMonitor/SystemMonitorWorkflow.php
Normal file
129
app/Temporal/SystemMonitor/SystemMonitorWorkflow.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\SystemMonitor;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class SystemMonitorWorkflow implements SystemMonitorWorkflowInterface
|
||||||
|
{
|
||||||
|
private bool $stopped = false;
|
||||||
|
private int $iteration = 0;
|
||||||
|
private int $totalIterations = 0;
|
||||||
|
private array $checkHistory = [];
|
||||||
|
private string $startedAt = '';
|
||||||
|
private string $lastCheckAt = '';
|
||||||
|
private float $healthScore = 100;
|
||||||
|
private string $status = 'initializing';
|
||||||
|
|
||||||
|
/** @var SystemMonitorActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
SystemMonitorActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::seconds(60))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(15))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()->withMaximumAttempts(3)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function monitor(int $intervalSeconds = 60, int $maxIterations = 30, array $state = [], array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
// Restore state from previous Continue-As-New run, or initialize
|
||||||
|
if (!empty($state)) {
|
||||||
|
$this->iteration = $state['iteration'] ?? 0;
|
||||||
|
$this->totalIterations = $state['totalIterations'] ?? $maxIterations;
|
||||||
|
$this->checkHistory = $state['checkHistory'] ?? [];
|
||||||
|
$this->startedAt = $state['startedAt'] ?? Workflow::now()->format('c');
|
||||||
|
$this->healthScore = $state['healthScore'] ?? 100;
|
||||||
|
} else {
|
||||||
|
$this->totalIterations = $maxIterations;
|
||||||
|
$this->startedAt = Workflow::now()->format('c');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'monitoring';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($this->iteration < $this->totalIterations && !$this->stopped) {
|
||||||
|
// Run health check activity
|
||||||
|
$result = yield $this->activityStub->runHealthCheck($simulationConfig);
|
||||||
|
|
||||||
|
// Store in check history (keep last 10)
|
||||||
|
$this->lastCheckAt = $result['timestamp'] ?? Workflow::now()->format('c');
|
||||||
|
$this->checkHistory[] = $result;
|
||||||
|
if (count($this->checkHistory) > 10) {
|
||||||
|
$this->checkHistory = array_slice($this->checkHistory, -10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update health score (rolling average of last checks)
|
||||||
|
$scores = array_column($this->checkHistory, 'healthScore');
|
||||||
|
$this->healthScore = count($scores) > 0 ? round(array_sum($scores) / count($scores), 1) : 100;
|
||||||
|
|
||||||
|
$this->iteration++;
|
||||||
|
|
||||||
|
// Check if we should Continue-As-New
|
||||||
|
if (Workflow::getInfo()->shouldContinueAsNew || ($this->iteration % 15 === 0 && $this->iteration < $this->totalIterations)) {
|
||||||
|
$continueStub = Workflow::newContinueAsNewStub(SystemMonitorWorkflowInterface::class);
|
||||||
|
|
||||||
|
return yield $continueStub->monitor(
|
||||||
|
$intervalSeconds,
|
||||||
|
$this->totalIterations,
|
||||||
|
[
|
||||||
|
'iteration' => $this->iteration,
|
||||||
|
'totalIterations' => $this->totalIterations,
|
||||||
|
'checkHistory' => $this->checkHistory,
|
||||||
|
'startedAt' => $this->startedAt,
|
||||||
|
'healthScore' => $this->healthScore,
|
||||||
|
],
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't sleep after the last iteration or if stopped
|
||||||
|
if ($this->iteration < $this->totalIterations && !$this->stopped) {
|
||||||
|
yield Workflow::timer($intervalSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = $this->stopped ? 'stopped' : 'completed';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'totalChecks' => $this->iteration,
|
||||||
|
'avgHealthScore' => $this->healthScore,
|
||||||
|
'lastCheckAt' => $this->lastCheckAt,
|
||||||
|
'startedAt' => $this->startedAt,
|
||||||
|
'checkHistory' => $this->checkHistory,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stop(): void
|
||||||
|
{
|
||||||
|
$this->stopped = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'stopped' => $this->stopped,
|
||||||
|
'iteration' => $this->iteration,
|
||||||
|
'totalIterations' => $this->totalIterations,
|
||||||
|
'healthScore' => $this->healthScore,
|
||||||
|
'startedAt' => $this->startedAt,
|
||||||
|
'lastCheckAt' => $this->lastCheckAt,
|
||||||
|
'checkHistory' => $this->checkHistory,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\SystemMonitor;
|
||||||
|
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
use Temporal\Workflow\SignalMethod;
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface SystemMonitorWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function monitor(int $intervalSeconds = 60, int $maxIterations = 30, array $state = [], array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function stop(): void;
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getStatus(): array;
|
||||||
|
}
|
||||||
140
app/Temporal/UserMigration/UserMigrationActivity.php
Normal file
140
app/Temporal/UserMigration/UserMigrationActivity.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\UserMigration;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class UserMigrationActivity implements UserMigrationActivityInterface
|
||||||
|
{
|
||||||
|
public function fetchExternalUsers(int $count, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
$users = [];
|
||||||
|
$pageSize = 25;
|
||||||
|
$totalPages = (int) ceil($count / $pageSize);
|
||||||
|
|
||||||
|
for ($page = 0; $page < $totalPages; $page++) {
|
||||||
|
Activity::heartbeat(['page' => $page + 1, 'totalPages' => $totalPages]);
|
||||||
|
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'fetchExternalUsers');
|
||||||
|
|
||||||
|
$start = $page * $pageSize + 1;
|
||||||
|
$end = min(($page + 1) * $pageSize, $count);
|
||||||
|
|
||||||
|
for ($i = $start; $i <= $end; $i++) {
|
||||||
|
$users[] = [
|
||||||
|
'name' => 'User ' . $i,
|
||||||
|
'email' => 'user_' . $i . '_' . Str::random(4) . '@example.com',
|
||||||
|
'legacy_id' => 'LEG-' . $i,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateUsers(array $users, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'validateUsers');
|
||||||
|
|
||||||
|
$validUsers = array_filter($users, function (array $user) {
|
||||||
|
if (empty($user['name'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($user['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$validUsers = array_values($validUsers);
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'Validated users: %d valid out of %d total',
|
||||||
|
count($validUsers),
|
||||||
|
count($users)
|
||||||
|
));
|
||||||
|
|
||||||
|
return $validUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createAccounts(array $users, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'createAccounts');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$createdIds = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($users as $index => $userData) {
|
||||||
|
Activity::heartbeat(sprintf('Creating account %d of %d', $index + 1, count($users)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $userData['name'],
|
||||||
|
'email' => $userData['email'],
|
||||||
|
'password' => Hash::make(Str::random(16)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createdIds[] = $user->id;
|
||||||
|
$created++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = [
|
||||||
|
'email' => $userData['email'],
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
|
||||||
|
Log::warning(sprintf(
|
||||||
|
'Failed to create account for %s: %s',
|
||||||
|
$userData['email'],
|
||||||
|
$e->getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'created' => $created,
|
||||||
|
'failed' => $failed,
|
||||||
|
'created_ids' => $createdIds,
|
||||||
|
'errors' => $errors,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendWelcomeEmails(array $userIds, array $simulationConfig = []): bool
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'sendWelcomeEmails');
|
||||||
|
|
||||||
|
foreach ($userIds as $id) {
|
||||||
|
Log::info(sprintf('Sending welcome email to user ID: %s', $id));
|
||||||
|
usleep(50000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateMigrationReport(int $totalUsers, int $processedUsers, int $failedUsers, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, 'generateMigrationReport');
|
||||||
|
|
||||||
|
$successRate = $totalUsers > 0
|
||||||
|
? round(($processedUsers / $totalUsers) * 100, 2)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_users' => $totalUsers,
|
||||||
|
'processed_users' => $processedUsers,
|
||||||
|
'failed_users' => $failedUsers,
|
||||||
|
'success_rate' => $successRate,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\UserMigration;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface UserMigrationActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function fetchExternalUsers(int $count, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function validateUsers(array $users, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function createAccounts(array $users, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function sendWelcomeEmails(array $userIds, array $simulationConfig = []): bool;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function generateMigrationReport(int $totalUsers, int $processedUsers, int $failedUsers, array $simulationConfig = []): array;
|
||||||
|
}
|
||||||
122
app/Temporal/UserMigration/UserMigrationWorkflow.php
Normal file
122
app/Temporal/UserMigration/UserMigrationWorkflow.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\UserMigration;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class UserMigrationWorkflow implements UserMigrationWorkflowInterface
|
||||||
|
{
|
||||||
|
private string $status = 'pending';
|
||||||
|
private int $totalUsers = 0;
|
||||||
|
private int $batchSize = 0;
|
||||||
|
private int $processedUsers = 0;
|
||||||
|
private int $failedUsers = 0;
|
||||||
|
private bool $isPaused = false;
|
||||||
|
private int $currentBatch = 0;
|
||||||
|
private int $totalBatches = 0;
|
||||||
|
private int $retryCount = 0;
|
||||||
|
private int $rateLimitHits = 0;
|
||||||
|
|
||||||
|
/** @var UserMigrationActivityInterface */
|
||||||
|
private $activityStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->activityStub = Workflow::newActivityStub(
|
||||||
|
UserMigrationActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::minutes(10))
|
||||||
|
->withHeartbeatTimeout(CarbonInterval::seconds(60))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(3)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function migrate(int $totalUsers, int $batchSize, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
$this->totalUsers = $totalUsers;
|
||||||
|
$this->batchSize = $batchSize;
|
||||||
|
$this->totalBatches = (int) ceil($totalUsers / $batchSize);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->status = 'fetching_users';
|
||||||
|
$users = yield $this->activityStub->fetchExternalUsers($totalUsers, $simulationConfig);
|
||||||
|
|
||||||
|
$this->status = 'processing';
|
||||||
|
|
||||||
|
for ($i = 0; $i < $this->totalBatches; $i++) {
|
||||||
|
if ($this->isPaused) {
|
||||||
|
$this->status = 'paused';
|
||||||
|
yield Workflow::await(fn () => !$this->isPaused);
|
||||||
|
$this->status = 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->currentBatch = $i + 1;
|
||||||
|
|
||||||
|
$batchUsers = array_slice($users, $i * $this->batchSize, $this->batchSize);
|
||||||
|
|
||||||
|
$validUsers = yield $this->activityStub->validateUsers($batchUsers, $simulationConfig);
|
||||||
|
|
||||||
|
$result = yield $this->activityStub->createAccounts($validUsers, $simulationConfig);
|
||||||
|
|
||||||
|
$this->processedUsers += $result['created'];
|
||||||
|
$this->failedUsers += $result['failed'];
|
||||||
|
|
||||||
|
$attempt = $result['attempt'] ?? 1;
|
||||||
|
if ($attempt > 1) {
|
||||||
|
$this->retryCount += ($attempt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield $this->activityStub->sendWelcomeEmails($result['created_ids'], $simulationConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'generating_report';
|
||||||
|
$report = yield $this->activityStub->generateMigrationReport(
|
||||||
|
$this->totalUsers,
|
||||||
|
$this->processedUsers,
|
||||||
|
$this->failedUsers,
|
||||||
|
$simulationConfig
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->status = 'failed';
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->status = 'completed';
|
||||||
|
|
||||||
|
return $report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pause(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resume(): void
|
||||||
|
{
|
||||||
|
$this->isPaused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProgress(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => $this->status,
|
||||||
|
'totalUsers' => $this->totalUsers,
|
||||||
|
'processedUsers' => $this->processedUsers,
|
||||||
|
'failedUsers' => $this->failedUsers,
|
||||||
|
'isPaused' => $this->isPaused,
|
||||||
|
'currentBatch' => $this->currentBatch,
|
||||||
|
'totalBatches' => $this->totalBatches,
|
||||||
|
'percentComplete' => $this->totalUsers > 0
|
||||||
|
? round(($this->processedUsers / $this->totalUsers) * 100, 2)
|
||||||
|
: 0.0,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'rateLimitHits' => $this->rateLimitHits,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\UserMigration;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\SignalMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface UserMigrationWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function migrate(int $totalUsers, int $batchSize, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function pause(): void;
|
||||||
|
|
||||||
|
#[SignalMethod]
|
||||||
|
public function resume(): void;
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getProgress(): array;
|
||||||
|
}
|
||||||
44
app/Temporal/WebhookDelivery/WebhookDeliveryActivity.php
Normal file
44
app/Temporal/WebhookDelivery/WebhookDeliveryActivity.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\WebhookDelivery;
|
||||||
|
|
||||||
|
use App\Models\WebhookDelivery;
|
||||||
|
use App\Temporal\Shared\FaultSimulator;
|
||||||
|
use Temporal\Activity;
|
||||||
|
|
||||||
|
class WebhookDeliveryActivity implements WebhookDeliveryActivityInterface
|
||||||
|
{
|
||||||
|
public function deliverToEndpoint(string $endpoint, array $payload, array $simulationConfig = []): array
|
||||||
|
{
|
||||||
|
$startTime = hrtime(true);
|
||||||
|
|
||||||
|
FaultSimulator::maybeApply($simulationConfig, "deliverToEndpoint:{$endpoint}");
|
||||||
|
|
||||||
|
// Simulate HTTP POST latency
|
||||||
|
usleep(mt_rand(100000, 400000));
|
||||||
|
|
||||||
|
$responseTime = (int) ((hrtime(true) - $startTime) / 1_000_000);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'delivered',
|
||||||
|
'statusCode' => 200,
|
||||||
|
'attempt' => Activity::getInfo()->attempt,
|
||||||
|
'responseTime' => $responseTime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deadLetter(string $endpoint, array $payload, string $reason): bool
|
||||||
|
{
|
||||||
|
WebhookDelivery::create([
|
||||||
|
'workflow_id' => Activity::getInfo()->workflowExecution->getID(),
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'payload' => $payload,
|
||||||
|
'status' => 'dead_lettered',
|
||||||
|
'attempts' => 0,
|
||||||
|
'last_error' => $reason,
|
||||||
|
'dead_lettered_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\WebhookDelivery;
|
||||||
|
|
||||||
|
use Temporal\Activity\ActivityInterface;
|
||||||
|
use Temporal\Activity\ActivityMethod;
|
||||||
|
|
||||||
|
#[ActivityInterface]
|
||||||
|
interface WebhookDeliveryActivityInterface
|
||||||
|
{
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function deliverToEndpoint(string $endpoint, array $payload, array $simulationConfig = []): array;
|
||||||
|
|
||||||
|
#[ActivityMethod]
|
||||||
|
public function deadLetter(string $endpoint, array $payload, string $reason): bool;
|
||||||
|
}
|
||||||
122
app/Temporal/WebhookDelivery/WebhookDeliveryWorkflow.php
Normal file
122
app/Temporal/WebhookDelivery/WebhookDeliveryWorkflow.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\WebhookDelivery;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Temporal\Activity\ActivityOptions;
|
||||||
|
use Temporal\Common\RetryOptions;
|
||||||
|
use Temporal\Workflow;
|
||||||
|
|
||||||
|
class WebhookDeliveryWorkflow implements WebhookDeliveryWorkflowInterface
|
||||||
|
{
|
||||||
|
private array $endpointStatuses = [];
|
||||||
|
private int $totalDelivered = 0;
|
||||||
|
private int $totalFailed = 0;
|
||||||
|
private int $totalDeadLettered = 0;
|
||||||
|
private int $retryCount = 0;
|
||||||
|
|
||||||
|
/** @var WebhookDeliveryActivityInterface */
|
||||||
|
private $deliveryStub;
|
||||||
|
|
||||||
|
/** @var WebhookDeliveryActivityInterface */
|
||||||
|
private $deadLetterStub;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->deliveryStub = Workflow::newActivityStub(
|
||||||
|
WebhookDeliveryActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::seconds(30))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()
|
||||||
|
->withMaximumAttempts(3)
|
||||||
|
->withInitialInterval(CarbonInterval::seconds(1))
|
||||||
|
->withBackoffCoefficient(3.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->deadLetterStub = Workflow::newActivityStub(
|
||||||
|
WebhookDeliveryActivityInterface::class,
|
||||||
|
ActivityOptions::new()
|
||||||
|
->withStartToCloseTimeout(CarbonInterval::seconds(10))
|
||||||
|
->withRetryOptions(
|
||||||
|
RetryOptions::new()->withMaximumAttempts(1)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliver(array $payload, array $endpoints, array $simulationConfig = []): \Generator
|
||||||
|
{
|
||||||
|
// Initialize endpoint statuses
|
||||||
|
foreach ($endpoints as $endpoint) {
|
||||||
|
$this->endpointStatuses[$endpoint] = [
|
||||||
|
'status' => 'pending',
|
||||||
|
'attempt' => 0,
|
||||||
|
'responseTime' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-out: deliver to all endpoints in parallel
|
||||||
|
$promises = [];
|
||||||
|
foreach ($endpoints as $endpoint) {
|
||||||
|
$this->endpointStatuses[$endpoint]['status'] = 'delivering';
|
||||||
|
|
||||||
|
$promises[$endpoint] = Workflow::async(function () use ($endpoint, $payload, $simulationConfig) {
|
||||||
|
try {
|
||||||
|
$result = yield $this->deliveryStub->deliverToEndpoint($endpoint, $payload, $simulationConfig);
|
||||||
|
return ['endpoint' => $endpoint, 'success' => true, 'result' => (array) $result];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['endpoint' => $endpoint, 'success' => false, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results from all parallel activities
|
||||||
|
foreach ($promises as $endpoint => $promise) {
|
||||||
|
$outcome = (array) (yield $promise);
|
||||||
|
|
||||||
|
if ($outcome['success']) {
|
||||||
|
$result = $outcome['result'];
|
||||||
|
$this->endpointStatuses[$endpoint] = [
|
||||||
|
'status' => 'delivered',
|
||||||
|
'attempt' => $result['attempt'] ?? 1,
|
||||||
|
'responseTime' => $result['responseTime'] ?? 0,
|
||||||
|
];
|
||||||
|
$this->totalDelivered++;
|
||||||
|
|
||||||
|
$attempt = $result['attempt'] ?? 1;
|
||||||
|
if ($attempt > 1) {
|
||||||
|
$this->retryCount += ($attempt - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->endpointStatuses[$endpoint]['status'] = 'failed';
|
||||||
|
$this->totalFailed++;
|
||||||
|
|
||||||
|
// Dead-letter failed deliveries
|
||||||
|
yield $this->deadLetterStub->deadLetter($endpoint, $payload, $outcome['error'] ?? 'Unknown error');
|
||||||
|
$this->endpointStatuses[$endpoint]['status'] = 'dead_lettered';
|
||||||
|
$this->totalDeadLettered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'completed',
|
||||||
|
'totalDelivered' => $this->totalDelivered,
|
||||||
|
'totalFailed' => $this->totalFailed,
|
||||||
|
'totalDeadLettered' => $this->totalDeadLettered,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'endpoints' => $this->endpointStatuses,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeliveryStatus(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'totalDelivered' => $this->totalDelivered,
|
||||||
|
'totalFailed' => $this->totalFailed,
|
||||||
|
'totalDeadLettered' => $this->totalDeadLettered,
|
||||||
|
'retryCount' => $this->retryCount,
|
||||||
|
'endpoints' => $this->endpointStatuses,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Temporal\WebhookDelivery;
|
||||||
|
|
||||||
|
use Temporal\Workflow\WorkflowInterface;
|
||||||
|
use Temporal\Workflow\WorkflowMethod;
|
||||||
|
use Temporal\Workflow\QueryMethod;
|
||||||
|
|
||||||
|
#[WorkflowInterface]
|
||||||
|
interface WebhookDeliveryWorkflowInterface
|
||||||
|
{
|
||||||
|
#[WorkflowMethod]
|
||||||
|
public function deliver(array $payload, array $endpoints, array $simulationConfig = []);
|
||||||
|
|
||||||
|
#[QueryMethod]
|
||||||
|
public function getDeliveryStatus(): array;
|
||||||
|
}
|
||||||
18
artisan
Executable file
18
artisan
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
20
bootstrap/app.php
Normal file
20
bootstrap/app.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->web(append: [
|
||||||
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\TemporalServiceProvider::class,
|
||||||
|
];
|
||||||
137
compose.yaml
Normal file
137
compose.yaml
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
services:
|
||||||
|
laravel.test:
|
||||||
|
build:
|
||||||
|
context: './docker/sail'
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
WWWGROUP: '${WWWGROUP}'
|
||||||
|
image: 'sail-8.5/temporal-app'
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
ports:
|
||||||
|
- '${APP_PORT:-80}:80'
|
||||||
|
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||||
|
environment:
|
||||||
|
WWWUSER: '${WWWUSER}'
|
||||||
|
LARAVEL_SAIL: 1
|
||||||
|
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||||
|
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||||
|
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
- '/var/run/docker.sock:/var/run/docker.sock'
|
||||||
|
- './docker/sail/start-container:/usr/local/bin/start-container'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
depends_on:
|
||||||
|
- pgsql
|
||||||
|
- redis
|
||||||
|
- temporal
|
||||||
|
temporal-worker:
|
||||||
|
image: 'sail-8.5/temporal-app'
|
||||||
|
restart: unless-stopped
|
||||||
|
command: /var/www/html/rr serve -c /var/www/html/.rr.yaml
|
||||||
|
volumes:
|
||||||
|
- '.:/var/www/html'
|
||||||
|
environment:
|
||||||
|
WWWUSER: '${WWWUSER}'
|
||||||
|
LARAVEL_SAIL: 1
|
||||||
|
DB_HOST: pgsql
|
||||||
|
DB_DATABASE: '${DB_DATABASE}'
|
||||||
|
DB_USERNAME: '${DB_USERNAME}'
|
||||||
|
DB_PASSWORD: '${DB_PASSWORD}'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
depends_on:
|
||||||
|
- temporal
|
||||||
|
- pgsql
|
||||||
|
temporal:
|
||||||
|
image: temporalio/auto-setup:latest
|
||||||
|
ports:
|
||||||
|
- '7233:7233'
|
||||||
|
environment:
|
||||||
|
- DB=postgres12
|
||||||
|
- DB_PORT=5432
|
||||||
|
- POSTGRES_USER=temporal
|
||||||
|
- POSTGRES_PWD=temporal
|
||||||
|
- POSTGRES_SEEDS=temporal-pgsql
|
||||||
|
depends_on:
|
||||||
|
temporal-pgsql:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
temporal-ui:
|
||||||
|
image: temporalio/ui:latest
|
||||||
|
ports:
|
||||||
|
- '8080:8080'
|
||||||
|
environment:
|
||||||
|
- TEMPORAL_ADDRESS=temporal:7233
|
||||||
|
depends_on:
|
||||||
|
- temporal
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
temporal-pgsql:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: temporal
|
||||||
|
POSTGRES_PASSWORD: temporal
|
||||||
|
volumes:
|
||||||
|
- 'sail-temporal-pgsql:/var/lib/postgresql/data'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "temporal"]
|
||||||
|
interval: 5s
|
||||||
|
retries: 10
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
pgsql:
|
||||||
|
image: 'postgres:18-alpine'
|
||||||
|
ports:
|
||||||
|
- '${FORWARD_DB_PORT:-5432}:5432'
|
||||||
|
environment:
|
||||||
|
PGPASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
POSTGRES_DB: '${DB_DATABASE}'
|
||||||
|
POSTGRES_USER: '${DB_USERNAME}'
|
||||||
|
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
|
||||||
|
volumes:
|
||||||
|
- 'sail-pgsql:/var/lib/postgresql'
|
||||||
|
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- pg_isready
|
||||||
|
- '-q'
|
||||||
|
- '-d'
|
||||||
|
- '${DB_DATABASE}'
|
||||||
|
- '-U'
|
||||||
|
- '${DB_USERNAME}'
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
redis:
|
||||||
|
image: 'redis:alpine'
|
||||||
|
ports:
|
||||||
|
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||||
|
volumes:
|
||||||
|
- 'sail-redis:/data'
|
||||||
|
networks:
|
||||||
|
- sail
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- redis-cli
|
||||||
|
- ping
|
||||||
|
retries: 3
|
||||||
|
timeout: 5s
|
||||||
|
networks:
|
||||||
|
sail:
|
||||||
|
driver: bridge
|
||||||
|
volumes:
|
||||||
|
sail-pgsql:
|
||||||
|
driver: local
|
||||||
|
sail-redis:
|
||||||
|
driver: local
|
||||||
|
sail-temporal-pgsql:
|
||||||
|
driver: local
|
||||||
91
composer.json
Normal file
91
composer.json
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"temporal/sdk": "^2.16"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.2.2",
|
||||||
|
"laravel/pint": "^1.24",
|
||||||
|
"laravel/sail": "^1.41",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^11.5.3"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
10226
composer.lock
generated
Normal file
10226
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
];
|
||||||
183
config/database.php
Normal file
183
config/database.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue supports a variety of backends via a single, unified
|
||||||
|
| API, giving you convenient access to each backend using identical
|
||||||
|
| syntax for each. The default queue connection is defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection options for every queue backend
|
||||||
|
| used by your application. An example configuration is provided for
|
||||||
|
| each backend supported by Laravel. You're also free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control how and where failed jobs are stored. Laravel ships with
|
||||||
|
| support for storing failed jobs in a simple file or in a database.
|
||||||
|
|
|
||||||
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'notifications' => [
|
||||||
|
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||||
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
217
config/session.php
Normal file
217
config/session.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines the default session driver that is utilized for
|
||||||
|
| incoming requests. Laravel supports a variety of storage options to
|
||||||
|
| persist session data. Database storage is a great default choice.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to expire immediately when the browser is closed then you may
|
||||||
|
| indicate that via the expire_on_close configuration option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it's stored. All encryption is performed
|
||||||
|
| automatically by Laravel and you may use the session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the "file" session driver, the session files are placed
|
||||||
|
| on disk. The default storage location is defined here; however, you
|
||||||
|
| are free to provide another location where they should be stored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table to
|
||||||
|
| be used to store sessions. Of course, a sensible default is defined
|
||||||
|
| for you; however, you're welcome to change this to another table.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => env('SESSION_TABLE', 'sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using one of the framework's cache driven session backends, you may
|
||||||
|
| define the cache store which should be used to store the session data
|
||||||
|
| between requests. This must match one of your defined cache stores.
|
||||||
|
|
|
||||||
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the session cookie that is created by
|
||||||
|
| the framework. Typically, you should not need to change this value
|
||||||
|
| since doing so does not grant a meaningful security improvement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application, but you're free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('SESSION_PATH', '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the domain and subdomains the session cookie is
|
||||||
|
| available to. By default, the cookie will be available to the root
|
||||||
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. It's unlikely you should disable this option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" to permit secure cross-site requests.
|
||||||
|
|
|
||||||
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||||
|
|
||||||
|
];
|
||||||
6
config/temporal.php
Normal file
6
config/temporal.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'address' => env('TEMPORAL_ADDRESS', 'localhost:7233'),
|
||||||
|
'task_queue' => env('TEMPORAL_TASK_QUEUE', 'laravel-tasks'),
|
||||||
|
];
|
||||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cache', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->mediumText('value');
|
||||||
|
$table->integer('expiration')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('cache_locks', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->string('owner');
|
||||||
|
$table->integer('expiration')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cache');
|
||||||
|
Schema::dropIfExists('cache_locks');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedTinyInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->integer('total_jobs');
|
||||||
|
$table->integer('pending_jobs');
|
||||||
|
$table->integer('failed_jobs');
|
||||||
|
$table->longText('failed_job_ids');
|
||||||
|
$table->mediumText('options')->nullable();
|
||||||
|
$table->integer('cancelled_at')->nullable();
|
||||||
|
$table->integer('created_at');
|
||||||
|
$table->integer('finished_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->text('connection');
|
||||||
|
$table->text('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
Schema::dropIfExists('job_batches');
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('products', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('sku')->unique();
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->decimal('price', 10, 2);
|
||||||
|
$table->integer('stock_quantity')->default(0);
|
||||||
|
$table->string('category')->nullable();
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->timestamp('imported_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('products');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('orders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('order_number')->unique();
|
||||||
|
$table->string('customer_name');
|
||||||
|
$table->string('customer_email');
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
$table->decimal('total_amount', 10, 2)->default(0);
|
||||||
|
$table->string('payment_id')->nullable();
|
||||||
|
$table->string('tracking_number')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('orders');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('order_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->integer('quantity');
|
||||||
|
$table->decimal('unit_price', 10, 2);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('order_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('import_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('workflow_id');
|
||||||
|
$table->string('run_id')->nullable();
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('file_path')->nullable();
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
$table->integer('total_records')->default(0);
|
||||||
|
$table->integer('processed_records')->default(0);
|
||||||
|
$table->integer('failed_records')->default(0);
|
||||||
|
$table->json('error_log')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('import_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhook_deliveries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('workflow_id');
|
||||||
|
$table->string('endpoint');
|
||||||
|
$table->json('payload');
|
||||||
|
$table->string('status')->default('pending');
|
||||||
|
$table->integer('attempts')->default(0);
|
||||||
|
$table->text('last_error')->nullable();
|
||||||
|
$table->timestamp('delivered_at')->nullable();
|
||||||
|
$table->timestamp('dead_lettered_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('workflow_id');
|
||||||
|
$table->index('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_deliveries');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('enrichment_results', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('record_type');
|
||||||
|
$table->unsignedBigInteger('record_id');
|
||||||
|
$table->json('geocode_result')->nullable();
|
||||||
|
$table->boolean('email_valid')->nullable();
|
||||||
|
$table->integer('credit_score')->nullable();
|
||||||
|
$table->timestamp('enriched_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['record_type', 'record_id']);
|
||||||
|
$table->index('record_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('enrichment_results');
|
||||||
|
}
|
||||||
|
};
|
||||||
25
database/seeders/DatabaseSeeder.php
Normal file
25
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
use WithoutModelEvents;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the application's database.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// User::factory(10)->create();
|
||||||
|
|
||||||
|
User::factory()->create([
|
||||||
|
'name' => 'Test User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
database/seeders/OrderSeeder.php
Normal file
80
database/seeders/OrderSeeder.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Models\OrderItem;
|
||||||
|
use App\Models\Product;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class OrderSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Create some products first
|
||||||
|
$products = [];
|
||||||
|
$categories = ['Electronics', 'Clothing', 'Home & Garden'];
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 10; $i++) {
|
||||||
|
$products[] = Product::updateOrCreate(
|
||||||
|
['sku' => 'DEMO-SKU-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT)],
|
||||||
|
[
|
||||||
|
'name' => 'Demo Product ' . $i,
|
||||||
|
'description' => 'A sample product for order fulfillment demo',
|
||||||
|
'price' => round(mt_rand(1000, 50000) / 100, 2),
|
||||||
|
'stock_quantity' => mt_rand(50, 500),
|
||||||
|
'category' => $categories[array_rand($categories)],
|
||||||
|
'status' => 'active',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 5 sample orders
|
||||||
|
$customers = [
|
||||||
|
['name' => 'Alice Johnson', 'email' => 'alice@example.com'],
|
||||||
|
['name' => 'Bob Smith', 'email' => 'bob@example.com'],
|
||||||
|
['name' => 'Carol Williams', 'email' => 'carol@example.com'],
|
||||||
|
['name' => 'David Brown', 'email' => 'david@example.com'],
|
||||||
|
['name' => 'Eve Davis', 'email' => 'eve@example.com'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($customers as $index => $customer) {
|
||||||
|
$order = Order::updateOrCreate(
|
||||||
|
['order_number' => 'ORD-' . str_pad((string) ($index + 1), 5, '0', STR_PAD_LEFT)],
|
||||||
|
[
|
||||||
|
'customer_name' => $customer['name'],
|
||||||
|
'customer_email' => $customer['email'],
|
||||||
|
'status' => 'pending',
|
||||||
|
'total_amount' => 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$totalAmount = 0;
|
||||||
|
$itemCount = mt_rand(1, 4);
|
||||||
|
|
||||||
|
$selectedProducts = array_rand($products, min($itemCount, count($products)));
|
||||||
|
if (!is_array($selectedProducts)) {
|
||||||
|
$selectedProducts = [$selectedProducts];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($selectedProducts as $productIndex) {
|
||||||
|
$product = $products[$productIndex];
|
||||||
|
$quantity = mt_rand(1, 5);
|
||||||
|
$unitPrice = $product->price;
|
||||||
|
|
||||||
|
OrderItem::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'unit_price' => $unitPrice,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$totalAmount += $quantity * $unitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order->update(['total_amount' => $totalAmount]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('Created 10 demo products and 5 sample orders');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
database/seeders/ProductImportSeeder.php
Normal file
36
database/seeders/ProductImportSeeder.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ProductImportSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$categories = ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books', 'Toys', 'Food', 'Health'];
|
||||||
|
|
||||||
|
$csv = "sku,name,description,price,stock_quantity,category\n";
|
||||||
|
|
||||||
|
for ($i = 1; $i <= 500; $i++) {
|
||||||
|
$sku = 'SKU-' . str_pad((string) $i, 5, '0', STR_PAD_LEFT);
|
||||||
|
$name = 'Product ' . $i;
|
||||||
|
$description = 'Description for product ' . $i . '. This is a sample product for the Temporal import demo.';
|
||||||
|
$price = round(mt_rand(100, 99999) / 100, 2);
|
||||||
|
$stock = mt_rand(0, 1000);
|
||||||
|
$category = $categories[array_rand($categories)];
|
||||||
|
|
||||||
|
$csv .= "{$sku},{$name},\"{$description}\",{$price},{$stock},{$category}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = storage_path('app/imports');
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents(storage_path('app/imports/products.csv'), $csv);
|
||||||
|
|
||||||
|
$this->command->info('Generated 500-row products.csv at storage/app/imports/products.csv');
|
||||||
|
}
|
||||||
|
}
|
||||||
16
database/seeders/TemporalDemoSeeder.php
Normal file
16
database/seeders/TemporalDemoSeeder.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class TemporalDemoSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call([
|
||||||
|
ProductImportSeeder::class,
|
||||||
|
OrderSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
docker/sail/Dockerfile
Normal file
97
docker/sail/Dockerfile
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
LABEL maintainer="Taylor Otwell"
|
||||||
|
|
||||||
|
ARG WWWGROUP
|
||||||
|
ARG NODE_VERSION=24
|
||||||
|
ARG MYSQL_CLIENT="mysql-client"
|
||||||
|
ARG POSTGRES_VERSION=18
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV TZ=UTC
|
||||||
|
ENV SUPERVISOR_PHP_COMMAND="/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80"
|
||||||
|
ENV SUPERVISOR_PHP_USER="sail"
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=0
|
||||||
|
|
||||||
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
|
RUN echo "Acquire::http::Pipeline-Depth 0;" > /etc/apt/apt.conf.d/99custom && \
|
||||||
|
echo "Acquire::http::No-Cache true;" >> /etc/apt/apt.conf.d/99custom && \
|
||||||
|
echo "Acquire::BrokenProxy true;" >> /etc/apt/apt.conf.d/99custom
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get upgrade -y \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& apt-get install -y gnupg gosu curl ca-certificates zip unzip git supervisor sqlite3 libcap2-bin libpng-dev python3 dnsutils librsvg2-bin fswatch ffmpeg nano \
|
||||||
|
&& curl -sS 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xb8dc7e53946656efbce4c1dd71daeaab4ad4cab6' | gpg --dearmor | tee /etc/apt/keyrings/ppa_ondrej_php.gpg > /dev/null \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/ppa_ondrej_php.gpg] https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble main" > /etc/apt/sources.list.d/ppa_ondrej_php.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y \
|
||||||
|
libgd3 \
|
||||||
|
php8.5-cli \
|
||||||
|
php8.5-dev \
|
||||||
|
php8.5-pgsql \
|
||||||
|
php8.5-sqlite3 \
|
||||||
|
php8.5-gd \
|
||||||
|
php8.5-curl \
|
||||||
|
php8.5-mongodb \
|
||||||
|
php8.5-imap \
|
||||||
|
php8.5-mysql \
|
||||||
|
php8.5-mbstring \
|
||||||
|
php8.5-xml \
|
||||||
|
php8.5-zip \
|
||||||
|
php8.5-bcmath \
|
||||||
|
php8.5-soap \
|
||||||
|
php8.5-intl \
|
||||||
|
php8.5-readline \
|
||||||
|
php8.5-ldap \
|
||||||
|
php8.5-msgpack \
|
||||||
|
php8.5-igbinary \
|
||||||
|
php8.5-redis \
|
||||||
|
#php8.5-swoole \
|
||||||
|
php8.5-memcached \
|
||||||
|
php8.5-pcov \
|
||||||
|
php8.5-imagick \
|
||||||
|
php8.5-xdebug \
|
||||||
|
&& curl -sLS https://getcomposer.org/installer | php -- --install-dir=/usr/bin/ --filename=composer \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_VERSION.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& npm install -g npm \
|
||||||
|
&& npm install -g pnpm \
|
||||||
|
&& npm install -g bun \
|
||||||
|
&& npx playwright install-deps \
|
||||||
|
&& curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | tee /etc/apt/keyrings/yarn.gpg >/dev/null \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \
|
||||||
|
&& curl -sS https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /etc/apt/keyrings/pgdg.gpg >/dev/null \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y yarn \
|
||||||
|
&& apt-get install -y $MYSQL_CLIENT \
|
||||||
|
&& apt-get install -y postgresql-client-$POSTGRES_VERSION \
|
||||||
|
&& apt-get -y autoremove \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
# Install gRPC and protobuf extensions for Temporal SDK
|
||||||
|
RUN pecl install grpc protobuf \
|
||||||
|
&& echo "extension=grpc.so" > /etc/php/8.5/cli/conf.d/30-grpc.ini \
|
||||||
|
&& echo "extension=protobuf.so" > /etc/php/8.5/cli/conf.d/30-protobuf.ini
|
||||||
|
|
||||||
|
RUN setcap "cap_net_bind_service=+ep" /usr/bin/php8.5
|
||||||
|
|
||||||
|
RUN userdel -r ubuntu
|
||||||
|
RUN groupadd --force -g $WWWGROUP sail
|
||||||
|
RUN useradd -ms /bin/bash --no-user-group -g $WWWGROUP -u 1337 sail
|
||||||
|
RUN git config --global --add safe.directory /var/www/html
|
||||||
|
|
||||||
|
COPY start-container /usr/local/bin/start-container
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY php.ini /etc/php/8.5/cli/conf.d/99-sail.ini
|
||||||
|
RUN chmod +x /usr/local/bin/start-container
|
||||||
|
|
||||||
|
EXPOSE 80/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["start-container"]
|
||||||
5
docker/sail/php.ini
Normal file
5
docker/sail/php.ini
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[PHP]
|
||||||
|
post_max_size = 100M
|
||||||
|
upload_max_filesize = 100M
|
||||||
|
variables_order = EGPCS
|
||||||
|
pcov.directory = .
|
||||||
34
docker/sail/start-container
Executable file
34
docker/sail/start-container
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [ "$SUPERVISOR_PHP_USER" != "root" ] && [ "$SUPERVISOR_PHP_USER" != "sail" ]; then
|
||||||
|
echo "You should set SUPERVISOR_PHP_USER to either 'sail' or 'root'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -z "$WWWUSER" ]; then
|
||||||
|
usermod -u $WWWUSER sail
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -S /var/run/docker.sock ]; then
|
||||||
|
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||||
|
if ! getent group "$DOCKER_GID" > /dev/null 2>&1; then
|
||||||
|
groupadd -g "$DOCKER_GID" docker-host
|
||||||
|
fi
|
||||||
|
usermod -aG "$DOCKER_GID" sail
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d /.composer ]; then
|
||||||
|
mkdir /.composer
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod -R ugo+rw /.composer
|
||||||
|
|
||||||
|
if [ $# -gt 0 ]; then
|
||||||
|
if [ "$SUPERVISOR_PHP_USER" = "root" ]; then
|
||||||
|
exec "$@"
|
||||||
|
else
|
||||||
|
exec gosu $WWWUSER "$@"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
fi
|
||||||
14
docker/sail/supervisord.conf
Normal file
14
docker/sail/supervisord.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
|
[program:php]
|
||||||
|
command=%(ENV_SUPERVISOR_PHP_COMMAND)s
|
||||||
|
user=%(ENV_SUPERVISOR_PHP_USER)s
|
||||||
|
environment=LARAVEL_SAIL="1"
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
2714
package-lock.json
generated
Normal file
2714
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@inertiajs/vue3": "^2.3.13",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
|
"vue": "^3.5.27"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
phpunit.xml
Normal file
34
phpunit.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Feature">
|
||||||
|
<directory>tests/Feature</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="DB_DATABASE" value="testing"/>
|
||||||
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
25
public/.htaccess
Normal file
25
public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Handle X-XSRF-Token Header
|
||||||
|
RewriteCond %{HTTP:x-xsrf-token} .
|
||||||
|
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
20
public/index.php
Normal file
20
public/index.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Determine if the application is in maintenance mode...
|
||||||
|
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||||
|
require $maintenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the request...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||||
|
|
||||||
|
$app->handleRequest(Request::capture());
|
||||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
66
resources/css/app.css
Normal file
66
resources/css/app.css
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
|
@source '../../storage/framework/views/*.php';
|
||||||
|
@source '../**/*.blade.php';
|
||||||
|
@source '../**/*.js';
|
||||||
|
@source '../**/*.vue';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||||
|
--font-mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||||
|
--color-deep: #0a0a14;
|
||||||
|
--color-panel: #12121e;
|
||||||
|
--color-section: #181826;
|
||||||
|
--color-input: #0e0e1a;
|
||||||
|
--color-border: #262638;
|
||||||
|
--color-border-hover: #363650;
|
||||||
|
--color-label: #6e6e88;
|
||||||
|
--color-value: #c0c0d0;
|
||||||
|
--color-muted: #484860;
|
||||||
|
--color-accent: #4ec9b0;
|
||||||
|
--color-accent-dim: #2a7868;
|
||||||
|
--color-warn: #d4a843;
|
||||||
|
--color-danger: #c75050;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-deep);
|
||||||
|
color: var(--color-value);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 6px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--color-deep); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--color-border-hover); }
|
||||||
|
|
||||||
|
.folder-content {
|
||||||
|
max-height: 2000px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.25s ease;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-collapsed {
|
||||||
|
max-height: 0 !important;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-glow {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-dot {
|
||||||
|
animation: pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-glow {
|
||||||
|
box-shadow: 0 0 6px rgba(78, 201, 176, 0.5),
|
||||||
|
0 0 2px rgba(78, 201, 176, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="number"] { -moz-appearance: textfield; }
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
83
resources/js/Components/DataEnrichment.vue
Normal file
83
resources/js/Components/DataEnrichment.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-[11px] text-muted leading-relaxed">
|
||||||
|
per-record parallel enrichment · per-API retry policies · multiple external APIs
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-muted/60">
|
||||||
|
<span class="font-mono">app/Temporal/DataEnrichment/</span>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/asynchronous-activity-completion" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Workflow::async()</a>
|
||||||
|
<a href="https://php.temporal.io/classes/Temporal-Common-RetryOptions.html" target="_blank" class="text-accent/50 hover:text-accent transition-colors">per-API RetryOptions</a>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/message-passing#queries" target="_blank" class="text-accent/50 hover:text-accent transition-colors">queries</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimulationControls v-model="simulation" />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="startEnrichment"
|
||||||
|
class="px-3 py-1 text-[11px] font-medium uppercase tracking-wider bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25 hover:border-accent/50 transition-all rounded-sm cursor-pointer">
|
||||||
|
Start Enrichment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full"
|
||||||
|
:class="isTerminal ? 'bg-accent' : 'bg-accent animate-pulse-dot'"></span>
|
||||||
|
<span class="text-[11px] text-value">{{ status.status || 'starting' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-[10px] text-muted">
|
||||||
|
<span>enriched <span class="text-value">{{ status.enrichedRecords || 0 }}</span> / {{ status.totalRecords || 0 }}</span>
|
||||||
|
<span>failed <span class="text-danger">{{ status.failedRecords || 0 }}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="status.apiStats" class="space-y-1 pt-1 border-t border-border/30">
|
||||||
|
<div class="text-[10px] text-muted uppercase tracking-wider">Per-API Stats</div>
|
||||||
|
<div v-for="(stats, api) in status.apiStats" :key="api"
|
||||||
|
class="flex items-center gap-3 text-[10px]">
|
||||||
|
<span class="text-label w-16">{{ api }}</span>
|
||||||
|
<span class="text-muted">calls <span class="text-value">{{ stats.calls }}</span></span>
|
||||||
|
<span class="text-muted">retries <span class="text-warn">{{ stats.retries }}</span></span>
|
||||||
|
<span class="text-muted">429s <span class="text-warn">{{ stats.rateLimits }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePolling } from '../composables/usePolling.js';
|
||||||
|
import SimulationControls from './SimulationControls.vue';
|
||||||
|
|
||||||
|
const simulation = ref({
|
||||||
|
failureRate: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowId = ref(null);
|
||||||
|
const status = ref({});
|
||||||
|
|
||||||
|
const isTerminal = computed(() =>
|
||||||
|
['completed', 'failed'].includes(status.value.status) || !!status.value.error
|
||||||
|
);
|
||||||
|
|
||||||
|
const { start: startPolling } = usePolling(async () => {
|
||||||
|
const { data } = await axios.get(`/temporal/enrichment/${workflowId.value}/status`);
|
||||||
|
if (data.error && !data.status) {
|
||||||
|
status.value = { ...status.value, status: 'completed' };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
status.value = data;
|
||||||
|
return isTerminal.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startEnrichment() {
|
||||||
|
const { data } = await axios.post('/temporal/enrichment/start', { simulation: simulation.value });
|
||||||
|
workflowId.value = data.workflow_id;
|
||||||
|
status.value = { status: 'starting' };
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
109
resources/js/Components/EloquentQuery.vue
Normal file
109
resources/js/Components/EloquentQuery.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-[11px] text-muted leading-relaxed">
|
||||||
|
aggregations · eager loading · chunked batch · transactions · cross-model joins
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-muted/60">
|
||||||
|
<span class="font-mono">app/Temporal/EloquentQuery/</span>
|
||||||
|
<a href="https://laravel.com/docs/eloquent" target="_blank" class="text-accent/50 hover:text-accent transition-colors">Eloquent ORM</a>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/message-passing#queries" target="_blank" class="text-accent/50 hover:text-accent transition-colors">queries</a>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-heartbeats" target="_blank" class="text-accent/50 hover:text-accent transition-colors">heartbeat</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimulationControls v-model="simulation" />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="startPipeline"
|
||||||
|
class="px-3 py-1 text-[11px] font-medium uppercase tracking-wider bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25 hover:border-accent/50 transition-all rounded-sm cursor-pointer">
|
||||||
|
Run Pipeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full"
|
||||||
|
:class="isTerminal ? 'bg-accent' : 'bg-accent animate-pulse-dot'"></span>
|
||||||
|
<span class="text-[11px] text-value">{{ status.status || 'starting' }}</span>
|
||||||
|
<span v-if="status.currentStep" class="text-[10px] text-muted">· {{ status.currentStep }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step progress -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div v-for="i in (status.totalSteps || 6)" :key="i"
|
||||||
|
class="h-1 flex-1 rounded-full transition-all duration-300"
|
||||||
|
:class="i <= (status.completedSteps || 0) ? 'bg-accent' : i === (status.completedSteps || 0) + 1 && !isTerminal ? 'bg-accent/40 animate-pulse' : 'bg-border'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-muted">
|
||||||
|
step {{ status.completedSteps || 0 }} / {{ status.totalSteps || 6 }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Totals bar -->
|
||||||
|
<div class="flex items-center gap-4 text-[10px] text-muted">
|
||||||
|
<span>queries <span class="text-value">{{ status.totalQueriesRun || 0 }}</span></span>
|
||||||
|
<span>rows affected <span class="text-value">{{ status.totalRowsAffected || 0 }}</span></span>
|
||||||
|
<span>time <span class="text-value">{{ (status.totalExecutionTimeMs || 0).toFixed(1) }}ms</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-step results -->
|
||||||
|
<div v-if="Object.keys(status.stepResults || {}).length" class="space-y-1 pt-1 border-t border-border/30">
|
||||||
|
<div v-for="(result, key) in status.stepResults" :key="key"
|
||||||
|
class="border border-border/20 rounded-sm">
|
||||||
|
<button @click="toggleStep(key)"
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1 text-left cursor-pointer hover:bg-deep/30 transition-colors">
|
||||||
|
<span class="text-[10px] text-accent">{{ expandedSteps[key] ? '▾' : '▸' }}</span>
|
||||||
|
<span class="text-[10px] text-label flex-1">{{ result.label }}</span>
|
||||||
|
<span class="text-[9px] text-muted">{{ result.queriesRun }}q · {{ result.rowsAffected }}r · {{ result.executionTimeMs?.toFixed(1) }}ms</span>
|
||||||
|
<span v-if="result.attempt > 1" class="text-[9px] text-warn">attempt {{ result.attempt }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedSteps[key]" class="px-2 pb-2">
|
||||||
|
<pre class="text-[9px] text-muted/80 overflow-x-auto max-h-48 whitespace-pre-wrap">{{ JSON.stringify(result.data, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePolling } from '../composables/usePolling.js';
|
||||||
|
import SimulationControls from './SimulationControls.vue';
|
||||||
|
|
||||||
|
const simulation = ref({
|
||||||
|
failureRate: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowId = ref(null);
|
||||||
|
const status = ref({});
|
||||||
|
const expandedSteps = reactive({});
|
||||||
|
|
||||||
|
const isTerminal = computed(() =>
|
||||||
|
['completed', 'failed'].includes(status.value.status) || !!status.value.error
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleStep(key) {
|
||||||
|
expandedSteps[key] = !expandedSteps[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start: startPolling } = usePolling(async () => {
|
||||||
|
const { data } = await axios.get(`/temporal/eloquent-query/${workflowId.value}/status`);
|
||||||
|
if (data.error && !data.status) {
|
||||||
|
status.value = { ...status.value, status: 'completed' };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
status.value = data;
|
||||||
|
return isTerminal.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startPipeline() {
|
||||||
|
const { data } = await axios.post('/temporal/eloquent-query/start', { simulation: simulation.value });
|
||||||
|
workflowId.value = data.workflow_id;
|
||||||
|
status.value = { status: 'starting' };
|
||||||
|
Object.keys(expandedSteps).forEach(k => delete expandedSteps[k]);
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
93
resources/js/Components/ExternalApiSync.vue
Normal file
93
resources/js/Components/ExternalApiSync.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-[11px] text-muted leading-relaxed">
|
||||||
|
cursor-based pagination · rate limiting · token refresh · pause/resume
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3 text-[10px] text-muted/60">
|
||||||
|
<span class="font-mono">app/Temporal/ExternalApiSync/</span>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-next-retry-delay" target="_blank" class="text-accent/50 hover:text-accent transition-colors">nextRetryDelay</a>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/failure-detection#activity-heartbeats" target="_blank" class="text-accent/50 hover:text-accent transition-colors">heartbeats</a>
|
||||||
|
<a href="https://docs.temporal.io/develop/php/message-passing#signals" target="_blank" class="text-accent/50 hover:text-accent transition-colors">signals</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimulationControls v-model="simulation" />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button @click="startSync"
|
||||||
|
class="px-3 py-1 text-[11px] font-medium uppercase tracking-wider bg-accent/15 text-accent border border-accent/30 hover:bg-accent/25 hover:border-accent/50 transition-all rounded-sm cursor-pointer">
|
||||||
|
Start Sync
|
||||||
|
</button>
|
||||||
|
<template v-if="workflowId">
|
||||||
|
<button v-if="!status.isPaused" @click="pauseSync"
|
||||||
|
class="px-2 py-0.5 text-[10px] uppercase tracking-wider text-warn border border-warn/30 hover:bg-warn/10 transition-all rounded-sm cursor-pointer">
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
<button v-else @click="resumeSync"
|
||||||
|
class="px-2 py-0.5 text-[10px] uppercase tracking-wider text-accent border border-accent/30 hover:bg-accent/10 transition-all rounded-sm cursor-pointer">
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="workflowId" class="bg-deep/50 border border-border rounded-sm p-3 space-y-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full"
|
||||||
|
:class="isTerminal ? 'bg-accent' : 'bg-accent animate-pulse-dot'"></span>
|
||||||
|
<span class="text-[11px] text-value">{{ status.status || 'starting' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-[10px] text-muted">
|
||||||
|
<span>pages <span class="text-value">{{ status.pagesFetched || 0 }}</span></span>
|
||||||
|
<span>records <span class="text-value">{{ status.recordsSynced || 0 }}</span></span>
|
||||||
|
<span>cursor <span class="text-value">{{ status.currentCursor || '0' }}</span></span>
|
||||||
|
<span v-if="status.retryCount">retries <span class="text-warn">{{ status.retryCount }}</span></span>
|
||||||
|
<span v-if="status.rateLimitHits">429s <span class="text-warn">{{ status.rateLimitHits }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { usePolling } from '../composables/usePolling.js';
|
||||||
|
import SimulationControls from './SimulationControls.vue';
|
||||||
|
|
||||||
|
const simulation = ref({
|
||||||
|
failureRate: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
rateLimiting: { enabled: false, hitChance: 25, retryAfterMs: 2000 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowId = ref(null);
|
||||||
|
const status = ref({});
|
||||||
|
|
||||||
|
const isTerminal = computed(() =>
|
||||||
|
['completed', 'failed'].includes(status.value.status) || !!status.value.error
|
||||||
|
);
|
||||||
|
|
||||||
|
const { start: startPolling } = usePolling(async () => {
|
||||||
|
const { data } = await axios.get(`/temporal/api-sync/${workflowId.value}/status`);
|
||||||
|
if (data.error && !data.status) {
|
||||||
|
status.value = { ...status.value, status: 'completed' };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
status.value = data;
|
||||||
|
return isTerminal.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startSync() {
|
||||||
|
const { data } = await axios.post('/temporal/api-sync/start', { simulation: simulation.value });
|
||||||
|
workflowId.value = data.workflow_id;
|
||||||
|
status.value = { status: 'starting' };
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseSync() {
|
||||||
|
await axios.post(`/temporal/api-sync/${workflowId.value}/pause`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeSync() {
|
||||||
|
await axios.post(`/temporal/api-sync/${workflowId.value}/resume`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user