Merge pull request 'KAN-11 Move remaining project metadata ownership to templates' (#94) from koda/kan-11-restliche-projekt-konfiguration-auf-templates-umstellen into epic/kan-3
linter / quality (pull_request) Successful in 1m24s
tests / ci (8.4) (pull_request) Failing after 1m46s

Reviewed-on: OpenSource/KanbanGit#94
This commit was merged in pull request #94.
This commit is contained in:
2026-06-23 21:21:27 +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 {
$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->permissionService->grant($actor, Permission::ProjectAdmin, $project);
@@ -50,19 +57,14 @@ class CreateProject
$project,
$template->configuration_snapshot ?? [],
);
$project->forceFill([
'project_template_id' => $template->id,
])->save();
}
if ($project->project_template_id === null) {
$baseTemplate = $this->createProjectBaseTemplate->handle($project->fresh() ?? $project);
$project->forceFill([
'project_template_id' => $baseTemplate->id,
])->save();
}
$baseTemplate->forceFill([
'configuration_snapshot' => $this->duplicateProjectConfiguration->export(
$project->fresh() ?? $project,
DuplicateProjectConfiguration::copyableAreas(),
),
])->save();
return $project->fresh() ?? $project;
});
+7 -3
View File
@@ -47,9 +47,13 @@ class CreateProjectTicket
$nextSequenceNumber = (int) Ticket::query()
->where('project_id', $project->id)
->max('sequence_number') + 1;
$ticketStatus = TicketStatus::query()
->where('project_id', $project->id)
->findOrFail(Arr::get($attributes, 'ticket_status_id'));
$ticketStatusQuery = TicketStatus::query()
->where(
$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([
'assignee_user_id' => Arr::get($attributes, 'assignee_user_id'),
@@ -24,12 +24,17 @@ class MoveProjectTicketRequest extends FormRequest
{
/** @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 [
'ticket_status_id' => [
'required',
'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 */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'replacement_ticket_type_id' => [
'nullable',
'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 */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'ticket_status_id' => [
'required',
'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'])],
];
@@ -2,8 +2,10 @@
namespace App\Http\Requests;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,21 @@ class StoreProjectTicketBranchRequest extends FormRequest
*/
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 [
'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 */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'acceptance_criteria' => ['nullable', 'array'],
@@ -66,17 +69,23 @@ class StoreProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'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' => [
'required',
'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' => [
'required',
'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'],
'title' => ['required', 'string', 'max:255'],
+6 -1
View File
@@ -28,6 +28,9 @@ class StoreTicketLinkRequest extends FormRequest
{
/** @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 [
'linked_ticket_id' => [
@@ -38,7 +41,9 @@ class StoreTicketLinkRequest extends FormRequest
'ticket_link_type_id' => [
'required',
'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 */
$project = $this->route('project');
$projectTemplateId = $project->project_template_id;
$ownershipColumn = $projectTemplateId === null ? 'project_id' : 'project_template_id';
$ownershipValue = $projectTemplateId ?? $project->id;
return [
'board_column_id' => [
'nullable',
'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'],
];
@@ -51,6 +51,9 @@ class UpdateProjectTicketRequest extends FormRequest
{
/** @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 [
'acceptance_criteria' => ['nullable', 'array'],
@@ -68,7 +71,9 @@ class UpdateProjectTicketRequest extends FormRequest
'field_values.*.ticket_field_definition_id' => [
'required',
'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_user_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
@@ -81,12 +86,16 @@ class UpdateProjectTicketRequest extends FormRequest
'ticket_priority_id' => [
'required',
'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' => [
'required',
'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'],
'story_points' => ['nullable', 'integer', 'min:0'],
@@ -141,7 +150,7 @@ class UpdateProjectTicketRequest extends FormRequest
$requestedTicketTypeId = Arr::get($validated, 'ticket_type_id');
$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);
if (
@@ -29,12 +29,17 @@ class UpdateTicketStatusTransitionsRequest extends FormRequest
{
/** @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 [
'allowed_ticket_status_ids' => ['nullable', 'array'],
'allowed_ticket_status_ids.*' => [
'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;
use App\Models\Project;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ValidateProjectTicketBranchRequest extends FormRequest
{
@@ -22,9 +24,21 @@ class ValidateProjectTicketBranchRequest extends FormRequest
*/
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 [
'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;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\BoardColumnFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
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\HasManyThrough;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class BoardColumn extends Model
{
/** @use HasFactory<BoardColumnFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $boardColumn): void {
$boardColumn->syncProjectTemplateOwnership();
});
}
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\NotificationEventType;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\NotificationActionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -23,12 +24,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'message_template',
'name',
'project_id',
'project_template_id',
])]
#[Hidden(['delivery_url'])]
class NotificationAction extends Model
{
/** @use HasFactory<NotificationActionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @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
{
return $query->where('enabled', true);
+40 -8
View File
@@ -59,51 +59,83 @@ class Project extends Model
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');
}
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');
}
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');
}
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');
}
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('position');
}
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');
}
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('repository_full_name');
}
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('id');
}
+10
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderKey;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\RepositoryConnectionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'base_url',
'owner_name',
'project_id',
'project_template_id',
'provider',
'provider_repository_id',
'repository_full_name',
@@ -24,6 +26,7 @@ class RepositoryConnection extends Model
{
/** @use HasFactory<RepositoryConnectionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
/**
* @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
{
return $this->belongsTo(Project::class);
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketFieldDefinitionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
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\HasMany;
#[Fillable(['field_type', 'label', 'position', 'project_id'])]
#[Fillable(['field_type', 'label', 'position', 'project_id', 'project_template_id'])]
class TicketFieldDefinition extends Model
{
/**
@@ -22,6 +23,14 @@ class TicketFieldDefinition extends Model
/** @use HasFactory<TicketFieldDefinitionFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketFieldDefinition): void {
$ticketFieldDefinition->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketLinkTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
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\HasMany;
#[Fillable(['label', 'counterpart_label', 'position', 'project_id'])]
#[Fillable(['label', 'counterpart_label', 'position', 'project_id', 'project_template_id'])]
class TicketLinkType extends Model
{
/** @use HasFactory<TicketLinkTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketLinkType): void {
$ticketLinkType->syncProjectTemplateOwnership();
});
}
public function counterpartLabel(): string
{
+10 -1
View File
@@ -2,17 +2,26 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketPriorityFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['name', 'position', 'project_id'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id'])]
class TicketPriority extends Model
{
/** @use HasFactory<TicketPriorityFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketPriority): void {
$ticketPriority->syncProjectTemplateOwnership();
});
}
public function project(): BelongsTo
{
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketStatusFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,15 +12,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
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
{
/** @use HasFactory<TicketStatusFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): 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);
});
}
+10 -1
View File
@@ -2,6 +2,7 @@
namespace App\Models;
use App\Models\Concerns\SyncsProjectTemplateOwnership;
use Database\Factories\TicketTypeFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
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\HasMany;
#[Fillable(['name', 'position', 'project_id', 'is_parent'])]
#[Fillable(['name', 'position', 'project_id', 'project_template_id', 'is_parent'])]
class TicketType extends Model
{
/** @use HasFactory<TicketTypeFactory> */
use HasFactory;
use SyncsProjectTemplateOwnership;
protected static function booted(): void
{
static::saving(function (self $ticketType): void {
$ticketType->syncProjectTemplateOwnership();
});
}
protected function casts(): array
{