Merge pull request 'kan-11' (#99) from koda/kan-11-restliche-projekt-konfiguration-auf-templates-umstellen into epic/kan-3
linter / quality (pull_request) Successful in 1m36s
tests / ci (8.4) (pull_request) Failing after 1m51s

Reviewed-on: OpenSource/KanbanGit#99
This commit was merged in pull request #99.
This commit is contained in:
2026-06-23 22:41:16 +00:00
26 changed files with 473 additions and 45 deletions
+13 -11
View File
@@ -27,6 +27,13 @@ class CreateProject
{ {
return DB::transaction(function () use ($attributes, $actor): Project { return DB::transaction(function () use ($attributes, $actor): Project {
$project = Project::query()->create(Arr::only($attributes, ['key', 'name'])); $project = Project::query()->create(Arr::only($attributes, ['key', 'name']));
$baseTemplate = $this->createProjectBaseTemplate->handle($project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
$project = $project->fresh() ?? $project;
$this->initializeProjectTicketMetadata->handle($project); $this->initializeProjectTicketMetadata->handle($project);
$this->permissionService->grant($actor, Permission::ProjectAdmin, $project); $this->permissionService->grant($actor, Permission::ProjectAdmin, $project);
@@ -50,19 +57,14 @@ class CreateProject
$project, $project,
$template->configuration_snapshot ?? [], $template->configuration_snapshot ?? [],
); );
$project->forceFill([
'project_template_id' => $template->id,
])->save();
} }
if ($project->project_template_id === null) { $baseTemplate->forceFill([
$baseTemplate = $this->createProjectBaseTemplate->handle($project->fresh() ?? $project); 'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
$project->fresh() ?? $project,
$project->forceFill([ DuplicateProjectConfiguration::copyableAreas(),
'project_template_id' => $baseTemplate->id, ),
])->save(); ])->save();
}
return $project->fresh() ?? $project; return $project->fresh() ?? $project;
}); });
+7 -3
View File
@@ -47,9 +47,13 @@ class CreateProjectTicket
$nextSequenceNumber = (int) Ticket::query() $nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id) ->where('project_id', $project->id)
->max('sequence_number') + 1; ->max('sequence_number') + 1;
$ticketStatus = TicketStatus::query() $ticketStatusQuery = TicketStatus::query()
->where('project_id', $project->id) ->where(
->findOrFail(Arr::get($attributes, 'ticket_status_id')); $project->project_template_id === null ? 'project_id' : 'project_template_id',
$project->project_template_id ?? $project->id,
);
$ticketStatus = $ticketStatusQuery->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticket = Ticket::query()->create([ $ticket = Ticket::query()->create([
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'), 'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
@@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'ticket_status_id' => [ 'ticket_status_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -29,12 +29,17 @@ class DestroyProjectTicketTypeRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'replacement_ticket_type_id' => [ 'replacement_ticket_type_id' => [
'nullable', 'nullable',
'integer', 'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id), Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -29,12 +29,17 @@ class MoveProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'ticket_status_id' => [ 'ticket_status_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])], 'redirect_to' => ['required', 'string', Rule::in(['backlog', 'board'])],
]; ];
@@ -2,8 +2,10 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectTicketBranchRequest extends FormRequest class StoreProjectTicketBranchRequest extends FormRequest
{ {
@@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'branch_name' => ['required', 'string', 'max:255'], 'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], 'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
]; ];
} }
} }
@@ -48,6 +48,9 @@ class StoreProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
@@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest
'ticket_priority_id' => [ 'ticket_priority_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'ticket_status_id' => [ 'ticket_status_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'ticket_type_id' => [ 'ticket_type_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id), Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'story_points' => ['nullable', 'integer', 'min:0'], 'story_points' => ['nullable', 'integer', 'min:0'],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
+6 -1
View File
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'linked_ticket_id' => [ 'linked_ticket_id' => [
@@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest
'ticket_link_type_id' => [ 'ticket_link_type_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_link_types', 'id')->where('project_id', $project->id), Rule::exists('ticket_link_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -26,12 +26,17 @@ class StoreTicketStatusRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'board_column_id' => [ 'board_column_id' => [
'nullable', 'nullable',
'integer', 'integer',
Rule::exists('board_columns', 'id')->where('project_id', $project->id), Rule::exists('board_columns', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
]; ];
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'acceptance_criteria' => ['nullable', 'array'], 'acceptance_criteria' => ['nullable', 'array'],
@@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest
'field_values.*.ticket_field_definition_id' => [ 'field_values.*.ticket_field_definition_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_field_definitions', 'id')->where('project_id', $project->id), Rule::exists('ticket_field_definitions', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'field_values.*.value_number' => ['nullable', 'numeric'], 'field_values.*.value_number' => ['nullable', 'numeric'],
'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')], 'field_values.*.value_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
@@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest
'ticket_priority_id' => [ 'ticket_priority_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_priorities', 'id')->where('project_id', $project->id), Rule::exists('ticket_priorities', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'ticket_type_id' => [ 'ticket_type_id' => [
'required', 'required',
'integer', 'integer',
Rule::exists('ticket_types', 'id')->where('project_id', $project->id), Rule::exists('ticket_types', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'story_points' => ['nullable', 'integer', 'min:0'], 'story_points' => ['nullable', 'integer', 'min:0'],
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id'); $requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$requestedTicketType = TicketType::query() $requestedTicketType = TicketType::query()
->where('project_id', $project->id) ->where($project->project_template_id === null ? 'project_id' : 'project_template_id', $project->project_template_id ?? $project->id)
->find($requestedTicketTypeId); ->find($requestedTicketTypeId);
if ( if (
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
{ {
/** @var Project $project */ /** @var Project $project */
$project = $this->route('project'); $project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'allowed_ticket_status_ids' => ['nullable', 'array'], 'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [ 'allowed_ticket_status_ids.*' => [
'integer', 'integer',
Rule::exists('ticket_statuses', 'id')->where('project_id', $project->id), Rule::exists('ticket_statuses', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
], ],
]; ];
} }
@@ -2,8 +2,10 @@
namespace App\Http\Requests; namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ValidateProjectTicketBranchRequest extends FormRequest class ValidateProjectTicketBranchRequest extends FormRequest
{ {
@@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
/** @var Project $project */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [ return [
'branch_name' => ['required', 'string', 'max:255'], 'branch_name' => ['required', 'string', 'max:255'],
'repository_connection_id' => ['required', 'integer', 'exists:repository_connections,id'], 'repository_connection_id' => [
'required',
'integer',
Rule::exists('repository_connections', 'id')->where(
fn ($query) => $query->where($ownershipColumn, $ownershipValue),
),
],
]; ];
} }
} }
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\BoardColumnFactory; use Database\Factories\BoardColumnFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,11 +11,19 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable(['name', 'position', 'project_id'])] #[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model class BoardColumn extends Model
{ {
/** @use HasFactory<BoardColumnFactory> */ /** @use HasFactory<BoardColumnFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $boardColumn): void {
$boardColumn->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo public function project(): BelongsTo
{ {
@@ -0,0 +1,41 @@
<?php
namespace App\Models\Concerns;
use App\Models\Project;
use App\Models\ProjectTemplate;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait SyncsProjectTemplateOwnership
{
protected function syncProjectTemplateOwnership(): void
{
$projectTemplateId = $this->getAttribute('project_template_id');
$projectId = $this->getAttribute('project_id');
if ($projectTemplateId === null && $projectId !== null) {
$projectTemplateId = Project::query()
->whereKey($projectId)
->value('project_template_id');
if ($projectTemplateId !== null) {
$this->setAttribute('project_template_id', $projectTemplateId);
}
}
if ($projectId === null && $projectTemplateId !== null) {
$projectId = ProjectTemplate::query()
->whereKey($projectTemplateId)
->value('project_id');
if ($projectId !== null) {
$this->setAttribute('project_id', $projectId);
}
}
}
public function projectTemplate(): BelongsTo
{
return $this->belongsTo(ProjectTemplate::class);
}
}
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\NotificationChannel; use App\Enums\NotificationChannel;
use App\Enums\NotificationEventType; use App\Enums\NotificationEventType;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\NotificationActionFactory; use Database\Factories\NotificationActionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'message_template', 'message_template',
'name', 'name',
'project_id', 'project_id',
'project_template_id',
])] ])]
#[Hidden(['delivery_url'])] #[Hidden(['delivery_url'])]
class NotificationAction extends Model class NotificationAction extends Model
{ {
/** @use HasFactory<NotificationActionFactory> */ /** @use HasFactory<NotificationActionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
/** /**
* @return array<string, string> * @return array<string, string>
@@ -45,6 +48,13 @@ class NotificationAction extends Model
]; ];
} }
protected static function booted(): void
{
static::saving(function (self $notificationAction): void {
$notificationAction->syncProjectTemplateOwnership();
});
}
public function scopeEnabled(Builder $query): Builder public function scopeEnabled(Builder $query): Builder
{ {
return $query->where('enabled', true); return $query->where('enabled', true);
+40 -8
View File
@@ -59,51 +59,83 @@ class Project extends Model
public function boardColumns(): HasMany public function boardColumns(): HasMany
{ {
return $this->hasMany(BoardColumn::class) return $this->hasMany(
BoardColumn::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketFieldDefinitions(): HasMany public function ticketFieldDefinitions(): HasMany
{ {
return $this->hasMany(TicketFieldDefinition::class) return $this->hasMany(
TicketFieldDefinition::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketPriorities(): HasMany public function ticketPriorities(): HasMany
{ {
return $this->hasMany(TicketPriority::class) return $this->hasMany(
TicketPriority::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketLinkTypes(): HasMany public function ticketLinkTypes(): HasMany
{ {
return $this->hasMany(TicketLinkType::class) return $this->hasMany(
TicketLinkType::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function ticketStatuses(): HasMany public function ticketStatuses(): HasMany
{ {
return $this->hasMany(TicketStatus::class) return $this->hasMany(
TicketStatus::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('board_column_id') ->orderBy('board_column_id')
->orderBy('position'); ->orderBy('position');
} }
public function ticketTypes(): HasMany public function ticketTypes(): HasMany
{ {
return $this->hasMany(TicketType::class) return $this->hasMany(
TicketType::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('position'); ->orderBy('position');
} }
public function repositoryConnections(): HasMany public function repositoryConnections(): HasMany
{ {
return $this->hasMany(RepositoryConnection::class) return $this->hasMany(
RepositoryConnection::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('provider') ->orderBy('provider')
->orderBy('repository_full_name'); ->orderBy('repository_full_name');
} }
public function notificationActions(): HasMany public function notificationActions(): HasMany
{ {
return $this->hasMany(NotificationAction::class) return $this->hasMany(
NotificationAction::class,
$this->project_template_id === null ? 'project_id' : 'project_template_id',
$this->project_template_id === null ? 'id' : 'project_template_id',
)
->orderBy('name') ->orderBy('name')
->orderBy('id'); ->orderBy('id');
} }
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Actions\Git\Data\GitRepository; use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey; use App\Actions\Git\GitProviderKey;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\RepositoryConnectionFactory; use Database\Factories\RepositoryConnectionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'base_url', 'base_url',
'owner_name', 'owner_name',
'project_id', 'project_id',
'project_template_id',
'provider', 'provider',
'provider_repository_id', 'provider_repository_id',
'repository_full_name', 'repository_full_name',
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
{ {
/** @use HasFactory<RepositoryConnectionFactory> */ /** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
/** /**
* @return array<string, string> * @return array<string, string>
@@ -35,6 +38,13 @@ class RepositoryConnection extends Model
]; ];
} }
protected static function booted(): void
{
static::saving(function (self $repositoryConnection): void {
$repositoryConnection->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo public function project(): BelongsTo
{ {
return $this->belongsTo(Project::class); return $this->belongsTo(Project::class);
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketFieldDefinitionFactory; use Database\Factories\TicketFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id'])] #[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model class TicketFieldDefinition extends Model
{ {
/** /**
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */ /** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketFieldDefinition): void {
$ticketFieldDefinition->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo public function project(): BelongsTo
{ {
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketLinkTypeFactory; use Database\Factories\TicketLinkTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])] #[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model class TicketLinkType extends Model
{ {
/** @use HasFactory<TicketLinkTypeFactory> */ /** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketLinkType): void {
$ticketLinkType->syncProjectTemplateOwnership();
});
}
public function counterpartLabel(): string public function counterpartLabel(): string
{ {
+10 -1
View File
@@ -2,17 +2,26 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketPriorityFactory; use Database\Factories\TicketPriorityFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['name', 'position', 'project_id'])] #[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model class TicketPriority extends Model
{ {
/** @use HasFactory<TicketPriorityFactory> */ /** @use HasFactory<TicketPriorityFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketPriority): void {
$ticketPriority->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo public function project(): BelongsTo
{ {
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketStatusFactory; use Database\Factories\TicketStatusFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable(['board_column_id', 'name', 'position', 'project_id', 'slug'])] #[Fillable(['board_column_id', 'name', 'position', 'project_id', 'project_template_id', 'slug'])]
class TicketStatus extends Model class TicketStatus extends Model
{ {
/** @use HasFactory<TicketStatusFactory> */ /** @use HasFactory<TicketStatusFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void protected static function booted(): void
{ {
static::saving(function (self $ticketStatus): void { static::saving(function (self $ticketStatus): void {
if ($ticketStatus->project_template_id === null && $ticketStatus->board_column_id !== null) {
$ticketStatus->project_template_id = BoardColumn::query()
->whereKey($ticketStatus->board_column_id)
->value('project_template_id');
}
$ticketStatus->syncProjectTemplateOwnership();
$ticketStatus->slug = Str::slug($ticketStatus->name); $ticketStatus->slug = Str::slug($ticketStatus->name);
}); });
} }
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketTypeFactory; use Database\Factories\TicketTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -9,11 +10,19 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['name', 'position', 'project_id', 'is_parent'])] #[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model class TicketType extends Model
{ {
/** @use HasFactory<TicketTypeFactory> */ /** @use HasFactory<TicketTypeFactory> */
use HasFactory; use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketType): void {
$ticketType->syncProjectTemplateOwnership();
});
}
protected function casts(): array protected function casts(): array
{ {
@@ -0,0 +1,116 @@
<?php
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* @var list<string>
*/
private array $tables = [
'board_columns',
'ticket_field_definitions',
'ticket_link_types',
'ticket_priorities',
'ticket_statuses',
'ticket_types',
'repository_connections',
'notification_actions',
];
public function up(): void
{
foreach ($this->tables as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->foreignId('project_template_id')
->nullable()
->after('project_id')
->constrained('project_templates')
->nullOnDelete();
});
}
$projects = DB::table('projects')
->leftJoin('project_templates', 'project_templates.id', '=', 'projects.project_template_id')
->select([
'projects.id',
'projects.key',
'projects.name',
'projects.project_template_id',
'project_templates.project_id as template_project_id',
])
->get();
foreach ($projects as $project) {
$projectTemplateId = $project->project_template_id;
if ($projectTemplateId === null || (int) ($project->template_project_id ?? 0) !== (int) $project->id) {
$projectTemplateId = DB::table('project_templates')->insertGetId([
'project_id' => $project->id,
'source_project_id' => $project->id,
'name' => $project->name.' base template',
'slug' => Str::lower($project->key).'-base-template',
'is_published' => false,
'copy_areas' => json_encode([
'project_settings',
'board_workflow',
'ticket_types',
'ticket_priorities',
'ticket_fields',
'ticket_link_types',
'repository_connections',
'notification_actions',
], JSON_THROW_ON_ERROR),
'configuration_snapshot' => json_encode([], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('projects')
->where('id', $project->id)
->update([
'project_template_id' => $projectTemplateId,
'updated_at' => now(),
]);
}
foreach ($this->tables as $table) {
DB::table($table)
->where('project_id', $project->id)
->update(['project_template_id' => $projectTemplateId]);
}
$projectModel = Project::query()->find($project->id);
if ($projectModel !== null) {
DB::table('project_templates')
->where('id', $projectTemplateId)
->update([
'configuration_snapshot' => json_encode(
app(DuplicateProjectConfiguration::class)->export(
$projectModel,
DuplicateProjectConfiguration::copyableAreas(),
),
JSON_THROW_ON_ERROR,
),
'updated_at' => now(),
]);
}
}
}
public function down(): void
{
foreach (array_reverse($this->tables) as $table) {
Schema::table($table, function (Blueprint $table): void {
$table->dropConstrainedForeignId('project_template_id');
});
}
}
};
+6 -1
View File
@@ -102,7 +102,12 @@ class ProjectApiTest extends TestCase
$project->refresh(); $project->refresh();
$this->assertSame($template->id, $project->project_template_id); $this->assertNotSame($template->id, $project->project_template_id);
$this->assertDatabaseHas('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id,
'is_published' => false,
]);
$this->assertFalse($project->show_author); $this->assertFalse($project->show_author);
$this->assertFalse($project->show_story_points); $this->assertFalse($project->show_story_points);
$this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all()); $this->assertSame(['Spike'], $project->ticketTypes()->pluck('name')->all());
@@ -2,6 +2,7 @@
namespace Tests\Feature\Database; namespace Tests\Feature\Database;
use App\Actions\Projects\DuplicateProjectConfiguration;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Tests\TestCase; use Tests\TestCase;
@@ -70,4 +71,76 @@ class ProjectTemplateBaselineMigrationTest extends TestCase
$this->assertSame(0, $template->is_published); $this->assertSame(0, $template->is_published);
$this->assertSame('lgcy-base-template', $template->slug); $this->assertSame('lgcy-base-template', $template->slug);
} }
public function test_metadata_migration_moves_existing_configuration_to_the_project_template_and_preserves_snapshot(): void
{
Artisan::call('migrate:fresh', [
'--path' => [
'database/migrations/0001_01_01_000000_create_users_table.php',
'database/migrations/0001_01_01_000001_create_cache_table.php',
'database/migrations/0001_01_01_000002_create_jobs_table.php',
'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php',
'database/migrations/2026_03_24_234007_create_projects_table.php',
'database/migrations/2026_03_24_234734_create_teams_table.php',
'database/migrations/2026_03_24_234735_create_team_user_table.php',
'database/migrations/2026_03_25_002100_create_board_columns_table.php',
'database/migrations/2026_03_25_003100_create_ticket_types_table.php',
'database/migrations/2026_03_25_003101_create_ticket_priorities_table.php',
'database/migrations/2026_03_25_003102_create_ticket_field_definitions_table.php',
'database/migrations/2026_03_25_004098_create_ticket_statuses_table.php',
'database/migrations/2026_03_25_004099_create_ticket_statuses_transitionstable.php',
'database/migrations/2026_03_25_004100_create_tickets_table.php',
'database/migrations/2026_03_25_065302_create_ticket_worklog_entries_table.php',
'database/migrations/2026_03_25_070706_create_ticket_field_values_table.php',
'database/migrations/2026_03_25_073715_create_ticket_links_table.php',
'database/migrations/2026_03_25_073715_create_ticket_link_types_table.php',
'database/migrations/2026_03_26_074522_create_user_git_connections_table.php',
'database/migrations/2026_03_26_075849_create_repository_connections_table.php',
'database/migrations/2026_04_12_120000_create_permission_assignments_table.php',
'database/migrations/2026_04_20_174556_create_user_applications_table.php',
'database/migrations/2026_04_20_194646_create_notification_actions_table.php',
'database/migrations/2026_04_20_194646_create_notification_delivery_logs_table.php',
'database/migrations/2026_04_24_194800_add_filters_to_notification_actions_table.php',
'database/migrations/2026_04_24_203500_create_acceptance_criteria_table.php',
'database/migrations/2026_05_19_230500_drop_acceptance_criteria_from_tickets_table.php',
'database/migrations/2026_05_22_124700_add_must_change_password_on_next_login_to_users_table.php',
'database/migrations/2026_05_23_101900_add_counterpart_label_to_ticket_link_types_table.php',
'database/migrations/2026_05_25_011500_create_project_templates_table.php',
'database/migrations/2026_05_25_013000_project_templates_as_project_baselines.php',
],
]);
DB::table('projects')->insert([
'name' => 'Legacy Project',
'key' => 'LGCY',
'show_author' => 0,
'show_story_points' => 1,
'project_template_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$projectId = (int) DB::table('projects')->where('key', 'LGCY')->value('id');
DB::table('ticket_types')->insert([
'project_id' => $projectId,
'name' => 'Bug',
'position' => 1,
'is_parent' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
Artisan::call('migrate', [
'--path' => 'database/migrations/2026_06_05_161500_move_project_metadata_to_project_templates.php',
]);
$project = DB::table('projects')->where('id', $projectId)->first();
$template = DB::table('project_templates')->where('id', $project->project_template_id)->first();
$ticketType = DB::table('ticket_types')->where('project_id', $projectId)->first();
$snapshot = json_decode((string) $template->configuration_snapshot, true, 512, JSON_THROW_ON_ERROR);
$this->assertSame($project->project_template_id, $ticketType->project_template_id);
$this->assertSame(['Bug'], array_column($snapshot[DuplicateProjectConfiguration::COPY_TICKET_TYPES], 'name'));
}
} }
@@ -285,7 +285,12 @@ class ProjectControllerTest extends TestCase
$project->refresh(); $project->refresh();
$this->assertSame($template->id, $project->project_template_id); $this->assertNotSame($template->id, $project->project_template_id);
$this->assertDatabaseHas('project_templates', [
'id' => $project->project_template_id,
'project_id' => $project->id,
'is_published' => false,
]);
$this->assertSame(false, $project->show_author); $this->assertSame(false, $project->show_author);
$this->assertSame(false, $project->show_story_points); $this->assertSame(false, $project->show_story_points);
$this->assertDatabaseHas('ticket_types', [ $this->assertDatabaseHas('ticket_types', [