OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
28
app/Core/WorkStructure/Events/ElementTypeRegistered.php
Normal file
28
app/Core/WorkStructure/Events/ElementTypeRegistered.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Fired when element types are added to a structure.
|
||||
*/
|
||||
class ElementTypeRegistered
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param int $structureId The structure ID
|
||||
* @param string $typeKey The element type key
|
||||
* @param string $label The element label
|
||||
*/
|
||||
public function __construct(
|
||||
public int $structureId,
|
||||
public string $typeKey,
|
||||
public string $label
|
||||
) {}
|
||||
}
|
||||
28
app/Core/WorkStructure/Events/MappingCreated.php
Normal file
28
app/Core/WorkStructure/Events/MappingCreated.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Fired when cross-structure mappings are defined.
|
||||
*/
|
||||
class MappingCreated
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param int $sourceStructureId The source structure ID
|
||||
* @param int $targetStructureId The target structure ID
|
||||
* @param string $mappingType The mapping type (generates, equivalent, informs)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $sourceStructureId,
|
||||
public int $targetStructureId,
|
||||
public string $mappingType
|
||||
) {}
|
||||
}
|
||||
28
app/Core/WorkStructure/Events/StructureRegistered.php
Normal file
28
app/Core/WorkStructure/Events/StructureRegistered.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Fired when a new work structure is registered.
|
||||
*/
|
||||
class StructureRegistered
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param int $structureId The registered structure ID
|
||||
* @param string $title The structure title
|
||||
* @param string $type The structure type (system, plugin, custom)
|
||||
*/
|
||||
public function __construct(
|
||||
public int $structureId,
|
||||
public string $title,
|
||||
public string $type
|
||||
) {}
|
||||
}
|
||||
28
app/Core/WorkStructure/Models/ElementDefinition.php
Normal file
28
app/Core/WorkStructure/Models/ElementDefinition.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Models;
|
||||
|
||||
/**
|
||||
* ElementDefinition model — defines an element type within a structure
|
||||
* (e.g., "milestone", "task", "goal").
|
||||
*/
|
||||
class ElementDefinition
|
||||
{
|
||||
public ?int $id = null;
|
||||
|
||||
public int $structureId = 0;
|
||||
|
||||
public string $typeKey = '';
|
||||
|
||||
public string $label = '';
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public ?string $domainReference = null;
|
||||
|
||||
public int $sortOrder = 0;
|
||||
|
||||
public ?string $meta = null;
|
||||
|
||||
public ?string $createdAt = null;
|
||||
}
|
||||
24
app/Core/WorkStructure/Models/RelationshipDefinition.php
Normal file
24
app/Core/WorkStructure/Models/RelationshipDefinition.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Models;
|
||||
|
||||
/**
|
||||
* RelationshipDefinition model — defines intra-structure relationships
|
||||
* (e.g., "task belongs_to milestone").
|
||||
*/
|
||||
class RelationshipDefinition
|
||||
{
|
||||
public ?int $id = null;
|
||||
|
||||
public int $structureId = 0;
|
||||
|
||||
public int $fromElementId = 0;
|
||||
|
||||
public int $toElementId = 0;
|
||||
|
||||
public string $relationshipType = '';
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public ?string $meta = null;
|
||||
}
|
||||
24
app/Core/WorkStructure/Models/StructureMapping.php
Normal file
24
app/Core/WorkStructure/Models/StructureMapping.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Models;
|
||||
|
||||
/**
|
||||
* StructureMapping model — defines cross-structure element mappings
|
||||
* (e.g., Logic Model "output" → Project "milestone").
|
||||
*/
|
||||
class StructureMapping
|
||||
{
|
||||
public ?int $id = null;
|
||||
|
||||
public int $sourceStructureId = 0;
|
||||
|
||||
public int $sourceElementId = 0;
|
||||
|
||||
public int $targetStructureId = 0;
|
||||
|
||||
public int $targetElementId = 0;
|
||||
|
||||
public string $mappingType = 'generates';
|
||||
|
||||
public ?string $meta = null;
|
||||
}
|
||||
31
app/Core/WorkStructure/Models/WorkStructure.php
Normal file
31
app/Core/WorkStructure/Models/WorkStructure.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Models;
|
||||
|
||||
/**
|
||||
* WorkStructure model — defines a structure type (e.g., "Project", "Logic Model").
|
||||
*/
|
||||
class WorkStructure
|
||||
{
|
||||
public ?int $id = null;
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $description = '';
|
||||
|
||||
public string $type = 'custom';
|
||||
|
||||
public ?int $createdBy = null;
|
||||
|
||||
public ?string $meta = null;
|
||||
|
||||
public ?string $createdAt = null;
|
||||
|
||||
public ?string $modifiedAt = null;
|
||||
|
||||
/** @var ElementDefinition[] */
|
||||
public array $elements = [];
|
||||
|
||||
/** @var RelationshipDefinition[] */
|
||||
public array $relationships = [];
|
||||
}
|
||||
344
app/Core/WorkStructure/Repositories/WorkStructureRepository.php
Normal file
344
app/Core/WorkStructure/Repositories/WorkStructureRepository.php
Normal file
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Repositories;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Leantime\Core\Db\Db as DbCore;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\WorkStructure\Models\ElementDefinition;
|
||||
use Leantime\Core\WorkStructure\Models\RelationshipDefinition;
|
||||
use Leantime\Core\WorkStructure\Models\StructureMapping;
|
||||
use Leantime\Core\WorkStructure\Models\WorkStructure;
|
||||
|
||||
/**
|
||||
* Repository for WorkStructure tables — CRUD via Laravel Query Builder.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class WorkStructureRepository
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private ConnectionInterface $db;
|
||||
|
||||
/**
|
||||
* @param DbCore $db Database connection wrapper
|
||||
*/
|
||||
public function __construct(DbCore $db)
|
||||
{
|
||||
$this->db = $db->getConnection();
|
||||
}
|
||||
|
||||
// ─── Structures ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a structure by ID.
|
||||
*
|
||||
* @param int $id Structure ID
|
||||
*/
|
||||
public function getStructure(int $id): ?WorkStructure
|
||||
{
|
||||
$row = $this->db->table('zp_work_structures')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
return $row ? $this->hydrateStructure($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a structure by title.
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*/
|
||||
public function getStructureByTitle(string $title): ?WorkStructure
|
||||
{
|
||||
$row = $this->db->table('zp_work_structures')
|
||||
->where('title', $title)
|
||||
->first();
|
||||
|
||||
return $row ? $this->hydrateStructure($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all structures, optionally filtered by type.
|
||||
*
|
||||
* @param string|null $type Filter by type ('system', 'plugin', 'custom')
|
||||
* @return WorkStructure[]
|
||||
*/
|
||||
public function getAllStructures(?string $type = null): array
|
||||
{
|
||||
$query = $this->db->table('zp_work_structures');
|
||||
|
||||
if ($type !== null) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
return $query->get()
|
||||
->map(fn ($row) => $this->hydrateStructure($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new structure.
|
||||
*
|
||||
* @param array $values Structure data
|
||||
* @return int Inserted ID
|
||||
*/
|
||||
public function createStructure(array $values): int
|
||||
{
|
||||
$now = now()->toDateTimeString();
|
||||
|
||||
return (int) $this->db->table('zp_work_structures')->insertGetId([
|
||||
'title' => $values['title'],
|
||||
'description' => $values['description'] ?? '',
|
||||
'type' => $values['type'] ?? 'custom',
|
||||
'created_by' => $values['createdBy'] ?? null,
|
||||
'meta' => isset($values['meta']) ? json_encode($values['meta']) : null,
|
||||
'created_at' => $now,
|
||||
'modified_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a structure exists by title.
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*/
|
||||
public function structureExists(string $title): bool
|
||||
{
|
||||
return $this->db->table('zp_work_structures')
|
||||
->where('title', $title)
|
||||
->exists();
|
||||
}
|
||||
|
||||
// ─── Elements ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all elements for a structure.
|
||||
*
|
||||
* @param int $structureId Structure ID
|
||||
* @return ElementDefinition[]
|
||||
*/
|
||||
public function getElements(int $structureId): array
|
||||
{
|
||||
return $this->db->table('zp_work_structure_elements')
|
||||
->where('structure_id', $structureId)
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn ($row) => $this->hydrateElement($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an element by structure ID and type key.
|
||||
*
|
||||
* @param int $structureId Structure ID
|
||||
* @param string $typeKey Element type key
|
||||
*/
|
||||
public function getElementByTypeKey(int $structureId, string $typeKey): ?ElementDefinition
|
||||
{
|
||||
$row = $this->db->table('zp_work_structure_elements')
|
||||
->where('structure_id', $structureId)
|
||||
->where('type_key', $typeKey)
|
||||
->first();
|
||||
|
||||
return $row ? $this->hydrateElement($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an element to a structure.
|
||||
*
|
||||
* @param array $values Element data
|
||||
* @return int Inserted ID
|
||||
*/
|
||||
public function addElement(array $values): int
|
||||
{
|
||||
return (int) $this->db->table('zp_work_structure_elements')->insertGetId([
|
||||
'structure_id' => $values['structureId'],
|
||||
'type_key' => $values['typeKey'],
|
||||
'label' => $values['label'],
|
||||
'description' => $values['description'] ?? '',
|
||||
'domain_reference' => $values['domainReference'] ?? null,
|
||||
'sort_order' => $values['sortOrder'] ?? 0,
|
||||
'meta' => isset($values['meta']) ? json_encode($values['meta']) : null,
|
||||
'created_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Relationships ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all relationships for a structure.
|
||||
*
|
||||
* @param int $structureId Structure ID
|
||||
* @return RelationshipDefinition[]
|
||||
*/
|
||||
public function getRelationships(int $structureId): array
|
||||
{
|
||||
return $this->db->table('zp_work_structure_relationships')
|
||||
->where('structure_id', $structureId)
|
||||
->get()
|
||||
->map(fn ($row) => $this->hydrateRelationship($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an intra-structure relationship.
|
||||
*
|
||||
* @param array $values Relationship data
|
||||
* @return int Inserted ID
|
||||
*/
|
||||
public function addRelationship(array $values): int
|
||||
{
|
||||
return (int) $this->db->table('zp_work_structure_relationships')->insertGetId([
|
||||
'structure_id' => $values['structureId'],
|
||||
'from_element_id' => $values['fromElementId'],
|
||||
'to_element_id' => $values['toElementId'],
|
||||
'relationship_type' => $values['relationshipType'],
|
||||
'description' => $values['description'] ?? '',
|
||||
'meta' => isset($values['meta']) ? json_encode($values['meta']) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Mappings ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all mappings between two structures.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @return StructureMapping[]
|
||||
*/
|
||||
public function getMappings(int $sourceStructureId, int $targetStructureId): array
|
||||
{
|
||||
return $this->db->table('zp_work_structure_mappings')
|
||||
->where('source_structure_id', $sourceStructureId)
|
||||
->where('target_structure_id', $targetStructureId)
|
||||
->get()
|
||||
->map(fn ($row) => $this->hydrateMapping($row))
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a cross-structure mapping.
|
||||
*
|
||||
* @param array $values Mapping data
|
||||
* @return int Inserted ID
|
||||
*/
|
||||
public function addMapping(array $values): int
|
||||
{
|
||||
return (int) $this->db->table('zp_work_structure_mappings')->insertGetId([
|
||||
'source_structure_id' => $values['sourceStructureId'],
|
||||
'source_element_id' => $values['sourceElementId'],
|
||||
'target_structure_id' => $values['targetStructureId'],
|
||||
'target_element_id' => $values['targetElementId'],
|
||||
'mapping_type' => $values['mappingType'] ?? 'generates',
|
||||
'meta' => isset($values['meta']) ? json_encode($values['meta']) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get distinct target structure IDs that have mappings from a given source.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @return int[]
|
||||
*/
|
||||
public function getTargetStructureIds(int $sourceStructureId): array
|
||||
{
|
||||
return $this->db->table('zp_work_structure_mappings')
|
||||
->where('source_structure_id', $sourceStructureId)
|
||||
->distinct()
|
||||
->pluck('target_structure_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mapping exists.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param int $sourceElementId Source element ID
|
||||
* @param int $targetStructureId Target structure ID
|
||||
*/
|
||||
public function mappingExists(int $sourceStructureId, int $sourceElementId, int $targetStructureId): bool
|
||||
{
|
||||
return $this->db->table('zp_work_structure_mappings')
|
||||
->where('source_structure_id', $sourceStructureId)
|
||||
->where('source_element_id', $sourceElementId)
|
||||
->where('target_structure_id', $targetStructureId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
// ─── Hydrators ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hydrate a WorkStructure model from a database row.
|
||||
*/
|
||||
private function hydrateStructure(object $row): WorkStructure
|
||||
{
|
||||
$model = new WorkStructure;
|
||||
$model->id = (int) $row->id;
|
||||
$model->title = $row->title;
|
||||
$model->description = $row->description ?? '';
|
||||
$model->type = $row->type;
|
||||
$model->createdBy = $row->created_by ? (int) $row->created_by : null;
|
||||
$model->meta = $row->meta;
|
||||
$model->createdAt = $row->created_at;
|
||||
$model->modifiedAt = $row->modified_at;
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate an ElementDefinition model from a database row.
|
||||
*/
|
||||
private function hydrateElement(object $row): ElementDefinition
|
||||
{
|
||||
$model = new ElementDefinition;
|
||||
$model->id = (int) $row->id;
|
||||
$model->structureId = (int) $row->structure_id;
|
||||
$model->typeKey = $row->type_key;
|
||||
$model->label = $row->label;
|
||||
$model->description = $row->description ?? '';
|
||||
$model->domainReference = $row->domain_reference;
|
||||
$model->sortOrder = (int) $row->sort_order;
|
||||
$model->meta = $row->meta;
|
||||
$model->createdAt = $row->created_at;
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a RelationshipDefinition model from a database row.
|
||||
*/
|
||||
private function hydrateRelationship(object $row): RelationshipDefinition
|
||||
{
|
||||
$model = new RelationshipDefinition;
|
||||
$model->id = (int) $row->id;
|
||||
$model->structureId = (int) $row->structure_id;
|
||||
$model->fromElementId = (int) $row->from_element_id;
|
||||
$model->toElementId = (int) $row->to_element_id;
|
||||
$model->relationshipType = $row->relationship_type;
|
||||
$model->description = $row->description ?? '';
|
||||
$model->meta = $row->meta;
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate a StructureMapping model from a database row.
|
||||
*/
|
||||
private function hydrateMapping(object $row): StructureMapping
|
||||
{
|
||||
$model = new StructureMapping;
|
||||
$model->id = (int) $row->id;
|
||||
$model->sourceStructureId = (int) $row->source_structure_id;
|
||||
$model->sourceElementId = (int) $row->source_element_id;
|
||||
$model->targetStructureId = (int) $row->target_structure_id;
|
||||
$model->targetElementId = (int) $row->target_element_id;
|
||||
$model->mappingType = $row->mapping_type;
|
||||
$model->meta = $row->meta;
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
170
app/Core/WorkStructure/Services/MappingService.php
Normal file
170
app/Core/WorkStructure/Services/MappingService.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Services;
|
||||
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\WorkStructure\Models\WorkStructure;
|
||||
use Leantime\Core\WorkStructure\Repositories\WorkStructureRepository;
|
||||
|
||||
/**
|
||||
* Cross-structure mapping query service.
|
||||
*
|
||||
* Resolves element type mappings between different work structures
|
||||
* (e.g., Logic Model "output" → Project "milestone").
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class MappingService
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* @param WorkStructureRepository $repo WorkStructure repository
|
||||
*/
|
||||
public function __construct(
|
||||
private WorkStructureRepository $repo
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get all mappings between two structures.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @return \Leantime\Core\WorkStructure\Models\StructureMapping[]
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getMappings(int $sourceStructureId, int $targetStructureId): array
|
||||
{
|
||||
return $this->repo->getMappings($sourceStructureId, $targetStructureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target element type key for a given source element type key.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param string $sourceTypeKey Source element type key
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @return string|null Target element type key, or null if no mapping exists
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getTargetElementKey(int $sourceStructureId, string $sourceTypeKey, int $targetStructureId): ?string
|
||||
{
|
||||
$sourceElement = $this->repo->getElementByTypeKey($sourceStructureId, $sourceTypeKey);
|
||||
|
||||
if ($sourceElement === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappings = $this->repo->getMappings($sourceStructureId, $targetStructureId);
|
||||
|
||||
// Resolve target element IDs → typeKeys with a single fetch instead of
|
||||
// re-querying all target elements inside the mappings loop.
|
||||
$targetIdToKey = [];
|
||||
foreach ($this->repo->getElements($targetStructureId) as $el) {
|
||||
$targetIdToKey[$el->id] = $el->typeKey;
|
||||
}
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
if ($mapping->sourceElementId === $sourceElement->id) {
|
||||
return $targetIdToKey[$mapping->targetElementId] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all target structures that have mappings from a given source structure.
|
||||
*
|
||||
* Each returned structure includes its elements populated.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @return WorkStructure[]
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getTargetStructures(int $sourceStructureId): array
|
||||
{
|
||||
$targetIds = $this->repo->getTargetStructureIds($sourceStructureId);
|
||||
$structures = [];
|
||||
|
||||
foreach ($targetIds as $targetId) {
|
||||
$structure = $this->repo->getStructure($targetId);
|
||||
|
||||
if ($structure !== null) {
|
||||
$structure->elements = $this->repo->getElements($targetId);
|
||||
$structures[] = $structure;
|
||||
}
|
||||
}
|
||||
|
||||
return $structures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get target element type keys that have mappings from the source structure.
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @return string[] Array of target element typeKeys (e.g., ['milestone', 'task', 'goal'])
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getMappedElementKeys(int $sourceStructureId, int $targetStructureId): array
|
||||
{
|
||||
$mappings = $this->repo->getMappings($sourceStructureId, $targetStructureId);
|
||||
$targetElements = $this->repo->getElements($targetStructureId);
|
||||
|
||||
$elementIdToKey = [];
|
||||
foreach ($targetElements as $el) {
|
||||
$elementIdToKey[$el->id] = $el->typeKey;
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ($mappings as $mapping) {
|
||||
$key = $elementIdToKey[$mapping->targetElementId] ?? null;
|
||||
if ($key !== null && ! in_array($key, $keys, true)) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the source element type key for a given target element type key.
|
||||
*
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @param string $targetTypeKey Target element type key
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @return string|null Source element type key, or null if no mapping exists
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getSourceElementKey(int $targetStructureId, string $targetTypeKey, int $sourceStructureId): ?string
|
||||
{
|
||||
$targetElement = $this->repo->getElementByTypeKey($targetStructureId, $targetTypeKey);
|
||||
|
||||
if ($targetElement === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappings = $this->repo->getMappings($sourceStructureId, $targetStructureId);
|
||||
|
||||
// Resolve source element IDs → typeKeys with a single fetch instead of
|
||||
// re-querying all source elements inside the mappings loop.
|
||||
$sourceIdToKey = [];
|
||||
foreach ($this->repo->getElements($sourceStructureId) as $el) {
|
||||
$sourceIdToKey[$el->id] = $el->typeKey;
|
||||
}
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
if ($mapping->targetElementId === $targetElement->id) {
|
||||
return $sourceIdToKey[$mapping->sourceElementId] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
221
app/Core/WorkStructure/Services/StructureRegistry.php
Normal file
221
app/Core/WorkStructure/Services/StructureRegistry.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\WorkStructure\Events\ElementTypeRegistered;
|
||||
use Leantime\Core\WorkStructure\Events\MappingCreated;
|
||||
use Leantime\Core\WorkStructure\Events\StructureRegistered;
|
||||
use Leantime\Core\WorkStructure\Models\WorkStructure;
|
||||
use Leantime\Core\WorkStructure\Repositories\WorkStructureRepository;
|
||||
|
||||
/**
|
||||
* Plugin registration service for work structures.
|
||||
*
|
||||
* Provides an idempotent API for plugins to register their structure
|
||||
* definitions, elements, relationships, and cross-structure mappings.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class StructureRegistry
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private const CACHE_PREFIX = 'workstructure.';
|
||||
|
||||
/**
|
||||
* @param WorkStructureRepository $repo WorkStructure repository
|
||||
*/
|
||||
public function __construct(
|
||||
private WorkStructureRepository $repo
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a structure with elements and relationships (idempotent).
|
||||
*
|
||||
* @param string $title Structure title (unique)
|
||||
* @param string $type 'system', 'plugin', 'custom'
|
||||
* @param array $elements Array of element definitions [{typeKey, label, description?, domainReference?, sortOrder, meta?}]
|
||||
* @param array $relationships Array of relationship defs [{fromTypeKey, toTypeKey, relationshipType, description?}]
|
||||
* @return int Structure ID
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function register(string $title, string $type, array $elements, array $relationships = []): int
|
||||
{
|
||||
// Plugins call this from boot-time register.php. During a fresh install or
|
||||
// a pending update the WorkStructure tables may not exist yet — skip rather
|
||||
// than crash boot; the plugin re-registers idempotently on the next request.
|
||||
if (! $this->schemaReady()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->has($title)) {
|
||||
$structure = $this->get($title);
|
||||
|
||||
// Cache said it exists but the row is gone (deleted/cache drift):
|
||||
// fall through and recreate instead of dereferencing null.
|
||||
if ($structure !== null) {
|
||||
return $structure->id;
|
||||
}
|
||||
|
||||
$this->clearCache($title);
|
||||
}
|
||||
|
||||
$structureId = $this->repo->createStructure([
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
]);
|
||||
|
||||
// Add elements
|
||||
$elementIds = [];
|
||||
foreach ($elements as $element) {
|
||||
$elementId = $this->repo->addElement([
|
||||
'structureId' => $structureId,
|
||||
'typeKey' => $element['typeKey'],
|
||||
'label' => $element['label'],
|
||||
'description' => $element['description'] ?? '',
|
||||
'domainReference' => $element['domainReference'] ?? null,
|
||||
'sortOrder' => $element['sortOrder'] ?? 0,
|
||||
'meta' => $element['meta'] ?? null,
|
||||
]);
|
||||
$elementIds[$element['typeKey']] = $elementId;
|
||||
|
||||
ElementTypeRegistered::dispatch($structureId, $element['typeKey'], $element['label']);
|
||||
}
|
||||
|
||||
// Add relationships (resolve typeKey → element ID)
|
||||
foreach ($relationships as $rel) {
|
||||
$fromId = $elementIds[$rel['fromTypeKey']] ?? null;
|
||||
$toId = $elementIds[$rel['toTypeKey']] ?? null;
|
||||
|
||||
if ($fromId !== null && $toId !== null) {
|
||||
$this->repo->addRelationship([
|
||||
'structureId' => $structureId,
|
||||
'fromElementId' => $fromId,
|
||||
'toElementId' => $toId,
|
||||
'relationshipType' => $rel['relationshipType'],
|
||||
'description' => $rel['description'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->clearCache($title);
|
||||
StructureRegistered::dispatch($structureId, $title, $type);
|
||||
|
||||
return $structureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cross-structure mappings (idempotent per source element + target structure).
|
||||
*
|
||||
* @param int $sourceStructureId Source structure ID
|
||||
* @param int $targetStructureId Target structure ID
|
||||
* @param array $mappings Array of [{sourceTypeKey, targetTypeKey, mappingType}]
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function registerMappings(int $sourceStructureId, int $targetStructureId, array $mappings): void
|
||||
{
|
||||
if (! $this->schemaReady()) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
$sourceElement = $this->repo->getElementByTypeKey($sourceStructureId, $mapping['sourceTypeKey']);
|
||||
$targetElement = $this->repo->getElementByTypeKey($targetStructureId, $mapping['targetTypeKey']);
|
||||
|
||||
if ($sourceElement === null || $targetElement === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->repo->mappingExists($sourceStructureId, $sourceElement->id, $targetStructureId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->repo->addMapping([
|
||||
'sourceStructureId' => $sourceStructureId,
|
||||
'sourceElementId' => $sourceElement->id,
|
||||
'targetStructureId' => $targetStructureId,
|
||||
'targetElementId' => $targetElement->id,
|
||||
'mappingType' => $mapping['mappingType'] ?? 'generates',
|
||||
]);
|
||||
|
||||
MappingCreated::dispatch($sourceStructureId, $targetStructureId, $mapping['mappingType'] ?? 'generates');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a structure is registered.
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function has(string $title): bool
|
||||
{
|
||||
if (! $this->schemaReady()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Cache::rememberForever(self::CACHE_PREFIX.'exists.'.$title, function () use ($title) {
|
||||
return $this->repo->structureExists($title);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered structure by title (cached).
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function get(string $title): ?WorkStructure
|
||||
{
|
||||
if (! $this->schemaReady()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Cache::rememberForever(self::CACHE_PREFIX.'structure.'.$title, function () use ($title) {
|
||||
$structure = $this->repo->getStructureByTitle($title);
|
||||
|
||||
if ($structure !== null && $structure->id !== null) {
|
||||
$structure->elements = $this->repo->getElements($structure->id);
|
||||
$structure->relationships = $this->repo->getRelationships($structure->id);
|
||||
}
|
||||
|
||||
return $structure;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a structure.
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*/
|
||||
private function clearCache(string $title): void
|
||||
{
|
||||
Cache::forget(self::CACHE_PREFIX.'exists.'.$title);
|
||||
Cache::forget(self::CACHE_PREFIX.'structure.'.$title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the WorkStructure schema has been created yet.
|
||||
*
|
||||
* Guards the boot-time plugin registration path against a fresh install or a
|
||||
* pending update where migration 30502 hasn't run. Cached per request so the
|
||||
* hasTable lookup doesn't repeat for every register()/has()/get() call.
|
||||
*/
|
||||
private function schemaReady(): bool
|
||||
{
|
||||
static $ready = null;
|
||||
|
||||
if ($ready === null) {
|
||||
$ready = Schema::hasTable('zp_work_structures');
|
||||
}
|
||||
|
||||
return $ready;
|
||||
}
|
||||
}
|
||||
129
app/Core/WorkStructure/Services/WorkStructureService.php
Normal file
129
app/Core/WorkStructure/Services/WorkStructureService.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure\Services;
|
||||
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\WorkStructure\Models\ElementDefinition;
|
||||
use Leantime\Core\WorkStructure\Models\RelationshipDefinition;
|
||||
use Leantime\Core\WorkStructure\Models\WorkStructure;
|
||||
use Leantime\Core\WorkStructure\Repositories\WorkStructureRepository;
|
||||
|
||||
/**
|
||||
* CRUD service for work structures, elements, and relationships.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class WorkStructureService
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
/**
|
||||
* @param WorkStructureRepository $repo WorkStructure repository
|
||||
*/
|
||||
public function __construct(
|
||||
private WorkStructureRepository $repo
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get a structure by ID with its elements and relationships.
|
||||
*
|
||||
* @param int $id Structure ID
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getStructure(int $id): ?WorkStructure
|
||||
{
|
||||
$structure = $this->repo->getStructure($id);
|
||||
|
||||
if ($structure !== null) {
|
||||
$structure->elements = $this->repo->getElements($id);
|
||||
$structure->relationships = $this->repo->getRelationships($id);
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a structure by title with its elements and relationships.
|
||||
*
|
||||
* @param string $title Structure title
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getStructureByTitle(string $title): ?WorkStructure
|
||||
{
|
||||
$structure = $this->repo->getStructureByTitle($title);
|
||||
|
||||
if ($structure !== null && $structure->id !== null) {
|
||||
$structure->elements = $this->repo->getElements($structure->id);
|
||||
$structure->relationships = $this->repo->getRelationships($structure->id);
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all elements for a structure, ordered by sort_order.
|
||||
*
|
||||
* @param int $structureId Structure ID
|
||||
* @return ElementDefinition[]
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getElements(int $structureId): array
|
||||
{
|
||||
return $this->repo->getElements($structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all intra-structure relationships.
|
||||
*
|
||||
* @param int $structureId Structure ID
|
||||
* @return RelationshipDefinition[]
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function getRelationships(int $structureId): array
|
||||
{
|
||||
return $this->repo->getRelationships($structureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new structure.
|
||||
*
|
||||
* @param array $values Structure data (title, description, type, createdBy, meta)
|
||||
* @return int Created structure ID
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function createStructure(array $values): int
|
||||
{
|
||||
return $this->repo->createStructure($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an element to a structure.
|
||||
*
|
||||
* @param array $values Element data (structureId, typeKey, label, description, domainReference, sortOrder, meta)
|
||||
* @return int Created element ID
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function addElement(array $values): int
|
||||
{
|
||||
return $this->repo->addElement($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an intra-structure relationship.
|
||||
*
|
||||
* @param array $values Relationship data (structureId, fromElementId, toElementId, relationshipType, description, meta)
|
||||
* @return int Created relationship ID
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
public function addRelationship(array $values): int
|
||||
{
|
||||
return $this->repo->addRelationship($values);
|
||||
}
|
||||
}
|
||||
66
app/Core/WorkStructure/WorkStructureServiceProvider.php
Normal file
66
app/Core/WorkStructure/WorkStructureServiceProvider.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\WorkStructure;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Leantime\Core\WorkStructure\Repositories\WorkStructureRepository;
|
||||
use Leantime\Core\WorkStructure\Services\MappingService;
|
||||
use Leantime\Core\WorkStructure\Services\StructureRegistry;
|
||||
use Leantime\Core\WorkStructure\Services\WorkStructureService;
|
||||
|
||||
/**
|
||||
* Registers the WorkStructure infrastructure and seeds the built-in
|
||||
* "Project" system structure.
|
||||
*
|
||||
* WorkStructure is a meta-model that describes how Leantime entities are
|
||||
* composed and how they map across structures (e.g., a Logic Model "output"
|
||||
* generates a Project "milestone"). It lives in Core — like Plugins and Auth —
|
||||
* because it governs domains rather than being one. Plugins register their own
|
||||
* structures and mappings through {@see StructureRegistry}.
|
||||
*/
|
||||
class WorkStructureServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bind the WorkStructure services as singletons.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(WorkStructureRepository::class);
|
||||
$this->app->singleton(StructureRegistry::class);
|
||||
$this->app->singleton(MappingService::class);
|
||||
$this->app->singleton(WorkStructureService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the built-in "Project" structure (idempotent).
|
||||
*
|
||||
* StructureRegistry::register() is cache-guarded and a no-op once the
|
||||
* structure exists, so this costs a cache read in steady state. Wrapped in
|
||||
* try/catch because the tables do not exist yet during a fresh install.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
try {
|
||||
/** @var StructureRegistry $registry */
|
||||
$registry = $this->app->make(StructureRegistry::class);
|
||||
|
||||
$registry->register(
|
||||
'Project',
|
||||
'system',
|
||||
[
|
||||
['typeKey' => 'milestone', 'label' => 'Milestone', 'domainReference' => 'Leantime\\Domain\\Tickets', 'sortOrder' => 1],
|
||||
['typeKey' => 'task', 'label' => 'Task', 'domainReference' => 'Leantime\\Domain\\Tickets', 'sortOrder' => 2],
|
||||
['typeKey' => 'goal', 'label' => 'Goal', 'domainReference' => 'Leantime\\Domain\\Goalcanvas', 'sortOrder' => 3],
|
||||
],
|
||||
[
|
||||
['fromTypeKey' => 'task', 'toTypeKey' => 'milestone', 'relationshipType' => 'belongs_to'],
|
||||
['fromTypeKey' => 'milestone', 'toTypeKey' => 'goal', 'relationshipType' => 'measures'],
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Tables may not exist yet during install/update — degrade gracefully.
|
||||
Log::debug('WorkStructure seed skipped: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user