Open a TaskStream board when a project is created through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled

`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:
2026-10-07 21:12:36 +02:00
parent 9390db18c2
commit d30dd971d7
15 changed files with 621 additions and 10 deletions

View File

@@ -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)) {

View File

@@ -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}$/'],
];
}

View File

@@ -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,