Open a TaskStream board when a project is created through the API
`taskstream: true` on POST /v1/projects also creates the project in TaskStream, public, as the caller (their Pireon token is passed on), and the answer carries `taskstream_key` and `taskstream_url`. The key is `taskstream_key` or one made from the default-language slug. The board is opened before the project is saved: if TaskStream refuses it, nothing is created here and the reason comes back as a validation error (502 when TaskStream does not answer). Machine tokens need the server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do not inherit the link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Projects\OpenInTaskStream;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\ProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
@@ -45,11 +46,21 @@ class ProjectController extends Controller
|
||||
return ProjectResource::collection($projects);
|
||||
}
|
||||
|
||||
public function store(ProjectRequest $request): JsonResponse
|
||||
/**
|
||||
* With `taskstream: true`, the project's board is opened in TaskStream
|
||||
* first; if that fails, nothing is created here either.
|
||||
*/
|
||||
public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Project::class);
|
||||
|
||||
$project = $this->save(new Project, $request);
|
||||
$project = $this->fill(new Project, $request);
|
||||
|
||||
if ($request->boolean('taskstream')) {
|
||||
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
|
||||
}
|
||||
|
||||
$project = $this->persist($project, $request);
|
||||
|
||||
return ProjectResource::make($project)->response()->setStatusCode(201);
|
||||
}
|
||||
@@ -65,7 +76,7 @@ class ProjectController extends Controller
|
||||
{
|
||||
$this->authorize('update', $project);
|
||||
|
||||
return ProjectResource::make($this->save($project, $request));
|
||||
return ProjectResource::make($this->persist($this->fill($project, $request), $request));
|
||||
}
|
||||
|
||||
public function destroy(Project $project): JsonResponse
|
||||
@@ -86,20 +97,24 @@ class ProjectController extends Controller
|
||||
return ProjectResource::make($project->load(self::WITH));
|
||||
}
|
||||
|
||||
protected function fill(Project $project, ProjectRequest $request): Project
|
||||
{
|
||||
return $project->fill(TranslatablePayload::merge(
|
||||
$project,
|
||||
collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(),
|
||||
$project->translatable,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The record and its relations in one transaction: a project that saved
|
||||
* but lost its team halfway would be worse than one that did not save.
|
||||
*/
|
||||
protected function save(Project $project, ProjectRequest $request): Project
|
||||
protected function persist(Project $project, ProjectRequest $request): Project
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($project, $data): void {
|
||||
$project->fill(TranslatablePayload::merge(
|
||||
$project,
|
||||
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
|
||||
$project->translatable,
|
||||
));
|
||||
$project->save();
|
||||
|
||||
if (array_key_exists('technology_ids', $data)) {
|
||||
|
||||
@@ -19,6 +19,9 @@ use Illuminate\Validation\Validator;
|
||||
*
|
||||
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
|
||||
* `service_ids`, and `members` with each one's role on the project.
|
||||
*
|
||||
* `taskstream` (on creation only) opens a public board in TaskStream for the
|
||||
* project, keyed `taskstream_key` or one derived from the slug.
|
||||
*/
|
||||
class ProjectRequest extends FormRequest
|
||||
{
|
||||
@@ -74,6 +77,11 @@ class ProjectRequest extends FormRequest
|
||||
'members' => ['sometimes', 'array'],
|
||||
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
|
||||
'members.*.role' => ['nullable', 'string', 'max:120'],
|
||||
|
||||
// Only when creating; a client PATCHing back what it read sends
|
||||
// the key it got, which is ignored rather than refused.
|
||||
'taskstream' => [$creating ? 'sometimes' : 'exclude', 'boolean'],
|
||||
'taskstream_key' => [$creating ? 'sometimes' : 'exclude', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\TaskStream\TaskStreamClient;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,6 +34,10 @@ class ProjectResource extends JsonResource
|
||||
'is_featured' => (bool) $this->is_featured,
|
||||
'needs_help' => (bool) $this->needs_help,
|
||||
'demo_url' => $this->demo_url,
|
||||
'taskstream_key' => $this->taskstream_key,
|
||||
'taskstream_url' => $this->taskstream_key !== null
|
||||
? app(TaskStreamClient::class)->projectUrl($this->taskstream_key)
|
||||
: null,
|
||||
// The stored path, which is what a client PATCHes back; what was
|
||||
// derived from it travels alongside, as on a page.
|
||||
'cover_image' => $this->cover_image,
|
||||
|
||||
Reference in New Issue
Block a user