*/ public function getByExtensionPoint(int $templateId, string $extensionPointName): Collection { return LayoutExtension::query() ->where('template_id', $templateId) ->extensionPoints() ->where('target_name', $extensionPointName) ->active() ->ordered() ->get(); } /** * 특정 레이아웃을 타겟으로 하는 오버레이 목록 조회 * * @param int $templateId 템플릿 ID * @param string $layoutName 레이아웃 이름 * @return Collection */ public function getOverlaysByLayout(int $templateId, string $layoutName): Collection { return LayoutExtension::query() ->where('template_id', $templateId) ->overlays() ->where('target_name', $layoutName) ->active() ->ordered() ->get(); } /** * 확장 등록 * * @param array $data 확장 데이터 * @return LayoutExtension 생성된 확장 모델 */ public function create(array $data): LayoutExtension { return LayoutExtension::create($data); } /** * 확장 등록 또는 업데이트 (upsert) * * 동일한 조건의 확장이 존재하면 업데이트하고, 없으면 생성합니다. * soft delete된 레코드도 복원하여 업데이트합니다. * * @param array $attributes 조회 조건 * @param array $values 생성/업데이트할 값 * @return LayoutExtension 생성 또는 업데이트된 확장 모델 */ public function updateOrCreate(array $attributes, array $values): LayoutExtension { // soft delete된 레코드도 포함하여 조회 $existing = LayoutExtension::withTrashed() ->where($attributes) ->first(); if ($existing) { // 기존 레코드가 있으면 복원 후 업데이트 if ($existing->trashed()) { $existing->restore(); } $existing->update($values); return $existing->fresh(); } // 없으면 새로 생성 return LayoutExtension::create(array_merge($attributes, $values)); } /** * 출처별 확장 삭제 (soft delete) * * @param LayoutSourceType $sourceType 출처 타입 * @param string $identifier 출처 식별자 * @return int 삭제된 레코드 수 */ public function softDeleteBySource(LayoutSourceType $sourceType, string $identifier): int { return LayoutExtension::query() ->bySource($sourceType, $identifier) ->delete(); } /** * 출처별 확장 복원 * * @param LayoutSourceType $sourceType 출처 타입 * @param string $identifier 출처 식별자 * @return int 복원된 레코드 수 */ public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int { return LayoutExtension::query() ->withTrashed() ->bySource($sourceType, $identifier) ->restore(); } /** * 출처별 확장 영구 삭제 * * @param LayoutSourceType $sourceType 출처 타입 * @param string $identifier 출처 식별자 * @return int 삭제된 레코드 수 */ public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int { return LayoutExtension::query() ->withTrashed() ->bySource($sourceType, $identifier) ->forceDelete(); } /** * 템플릿 오버라이드 확인 (Extension Point용) * * @param int $templateId 템플릿 ID * @param string $extensionPointName 확장점 이름 * @param string $moduleIdentifier 모듈/플러그인 식별자 * @return LayoutExtension|null 템플릿 오버라이드 또는 null */ public function findTemplateOverrideForExtensionPoint( int $templateId, string $extensionPointName, string $moduleIdentifier ): ?LayoutExtension { return LayoutExtension::query() ->where('template_id', $templateId) ->extensionPoints() ->where('target_name', $extensionPointName) ->templateOverrides() ->overridingTarget($moduleIdentifier) ->active() ->first(); } /** * 템플릿 오버라이드 확인 (Overlay용) * * @param int $templateId 템플릿 ID * @param string $layoutName 레이아웃 이름 * @param string $moduleIdentifier 모듈/플러그인 식별자 * @return LayoutExtension|null 템플릿 오버라이드 또는 null */ public function findTemplateOverrideForOverlay( int $templateId, string $layoutName, string $moduleIdentifier ): ?LayoutExtension { return LayoutExtension::query() ->where('template_id', $templateId) ->overlays() ->where('target_name', $layoutName) ->templateOverrides() ->overridingTarget($moduleIdentifier) ->active() ->first(); } /** * 오버라이드를 고려한 Extension Point 조회 * * @param int $templateId 템플릿 ID * @param string $extensionPointName 확장점 이름 * @return Collection */ public function getResolvedExtensionPoints(int $templateId, string $extensionPointName): Collection { // 1. 모든 확장 조회 (모듈/플러그인/템플릿 오버라이드 포함) $allExtensions = LayoutExtension::query() ->where('template_id', $templateId) ->extensionPoints() ->where('target_name', $extensionPointName) ->active() ->ordered() ->get(); // 2. 템플릿 오버라이드에 가려진 모듈/플러그인 확장 제거 return $this->resolveOverrides($allExtensions); } /** * 오버라이드를 고려한 Overlay 조회 * * @param int $templateId 템플릿 ID * @param string $layoutName 레이아웃 이름 * @return Collection */ public function getResolvedOverlays(int $templateId, string $layoutName): Collection { // 1. 모든 오버레이 조회 $allOverlays = LayoutExtension::query() ->where('template_id', $templateId) ->overlays() ->where('target_name', $layoutName) ->active() ->ordered() ->get(); // 2. 템플릿 오버라이드에 가려진 모듈/플러그인 확장 제거 return $this->resolveOverrides($allOverlays); } /** * 템플릿 오버라이드 해석을 적용해 가려진 확장을 제거합니다. * * 동일한 (extension_type, target_name) 범위 안에서, 템플릿이 특정 * 모듈/플러그인을 오버라이드하면 그 모듈/플러그인의 원본 확장은 * 화면에 적용되지 않으므로 결과에서 제외합니다. 템플릿 오버라이드 행은 * 항상 유지됩니다. * * 렌더링 경로(getResolvedExtensionPoints/getResolvedOverlays)와 * 편집 화면 트리(getByTemplateId)가 동일한 가시성 기준을 공유하도록 * 이 메서드를 단일 출처(SSoT)로 사용합니다. * * 호출 측은 동일한 (extension_type, target_name) 범위의 확장만 * 전달해야 합니다. 혼합 컬렉션은 target_name 별로 그룹핑한 뒤 * 그룹마다 호출하십시오. * * @param Collection $extensions 동일 범위 확장 컬렉션 * @return Collection 오버라이드에 가려진 행을 제외한 컬렉션 */ public function resolveOverrides(Collection $extensions): Collection { // 템플릿 오버라이드가 가리키는 모듈/플러그인 식별자 추출 $overriddenSources = $extensions ->where('source_type', LayoutSourceType::Template) ->pluck('override_target') ->filter() ->unique() ->toArray(); return $extensions->filter(function ($extension) use ($overriddenSources) { // 템플릿 오버라이드는 항상 포함 if ($extension->source_type === LayoutSourceType::Template) { return true; } // 오버라이드된 모듈/플러그인 확장은 제외 return ! in_array($extension->source_identifier, $overriddenSources, true); })->values(); } /** * 특정 템플릿의 모든 확장 조회 * * @param int $templateId 템플릿 ID * @return Collection */ public function getByTemplateId(int $templateId): Collection { return LayoutExtension::query() ->where('template_id', $templateId) ->active() ->ordered() ->get(); } /** * 특정 템플릿의 확장을 오버라이드 해석을 적용해 조회합니다. * * getByTemplateId 가 모든 행을 반환하는 것과 달리, 템플릿 오버라이드에 * 가려져 화면에 적용되지 않는 모듈/플러그인 확장을 제외합니다. * 레이아웃 편집 화면 좌측 트리가 "실제 화면에 반영되는 확장"만 보여주도록 * 렌더링 경로와 동일한 가시성 기준(resolveOverrides)을 적용합니다. * * @param int $templateId 템플릿 ID * @return Collection 오버라이드에 가려진 행을 제외한 컬렉션 */ public function getResolvedByTemplateId(int $templateId): Collection { $all = $this->getByTemplateId($templateId); // (extension_type, target_name) 별로 그룹핑한 뒤 그룹마다 오버라이드 해석 return $all ->groupBy(fn ($extension) => $extension->extension_type->value.'|'.$extension->target_name) ->flatMap(fn (Collection $group) => $this->resolveOverrides($group)) ->values(); } /** * ID로 단일 확장 조회 * * @param int $extensionId 확장 ID * @return LayoutExtension|null 확장 모델 또는 null */ public function findById(int $extensionId): ?LayoutExtension { return LayoutExtension::find($extensionId); } /** * 특정 출처(모듈/플러그인)의 모든 확장 조회 * * @param LayoutSourceType $sourceType 출처 타입 * @param string $identifier 출처 식별자 * @return Collection */ public function getBySource(LayoutSourceType $sourceType, string $identifier): Collection { return LayoutExtension::query() ->bySource($sourceType, $identifier) ->get(); } /** * 조회 조건에 일치하는 확장을 조회합니다. * * @param array $attributes 조회 조건 * @return LayoutExtension|null 일치하는 확장 또는 null */ public function findByAttributes(array $attributes): ?LayoutExtension { return LayoutExtension::query()->where($attributes)->first(); } /** * 조회 조건에 일치하는 모든 LIVE 확장을 템플릿 구분 없이 조회합니다. * * cross-template 판정에 사용 — 동일 확장(source/type/target)이 어느 템플릿들에 * 등록돼 있는지 전수 확인하기 위함. template_id 는 조건에 넣지 않는다. * * @param array $attributes 조회 조건 (template_id 제외) * @return \Illuminate\Database\Eloquent\Collection */ public function getAllByAttributesAcrossTemplates(array $attributes): \Illuminate\Database\Eloquent\Collection { return LayoutExtension::query()->where($attributes)->get(); } /** * 확장 업데이트 * * @param int $extensionId 확장 ID * @param array $data 업데이트할 데이터 * @return LayoutExtension 업데이트된 확장 모델 */ public function update(int $extensionId, array $data): LayoutExtension { $extension = LayoutExtension::findOrFail($extensionId); $extension->update($data); return $extension->fresh(); } /** * 확장 content + lock_version 동시 갱신 (낙관적 잠금) * * Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로, * 본 메서드는 update 데이터에 lock_version 증가를 함께 반영한다. * * @param int $extensionId 확장 ID * @param array $data 업데이트 데이터 (content, priority 등) * @param int $newLockVersion 새 lock_version 값 (currentVersion + 1) * @return LayoutExtension 업데이트된 확장 모델 */ public function updateWithLock(int $extensionId, array $data, int $newLockVersion): LayoutExtension { $extension = LayoutExtension::findOrFail($extensionId); $extension->update([...$data, 'lock_version' => $newLockVersion]); return $extension->fresh(); } /** * 특정 템플릿의 모든 확장 삭제 * * @param int $templateId 템플릿 ID * @return int 삭제된 레코드 수 */ public function deleteByTemplateId(int $templateId): int { return LayoutExtension::query() ->withTrashed() ->where('template_id', $templateId) ->forceDelete(); } }