templateService->findByIdentifier($templateName); if (! $template) { return $this->notFound('common.not_found'); } $groups = $this->layoutExtensionService->getExtensionsByTemplateId($template->id); // 그룹별 extensions 를 LayoutExtensionResource 컬렉션으로 직렬화. // 각 확장에 호스트 레이아웃 목록(host_layouts)을 부착해, 라우트 트리가 클릭(캔버스 로드) // 없이도 layoutName 매칭으로 화면별 연결 확장 목록을 정적 구성하게 한다. $data = array_map(function (array $group): array { foreach ($group['extensions'] as $extension) { $extension->setAttribute( 'host_layouts', $this->layoutExtensionService->getExtensionHostLayouts($extension) ); } $group['extensions'] = LayoutExtensionResource::collection($group['extensions']); return $group; }, $groups); return $this->success('common.success', $data); } /** * 특정 레이아웃 확장 상세 조회 * * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @return JsonResponse 확장 상세 응답 */ public function show(string $templateName, int $extensionId): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } // 호스트 레이아웃 후보 — 확장 편집 모드 캔버스가 호스트 병합 // 렌더할 대상. overlay = [target_layout], extension_point = 그 확장점을 포함하는 // 레이아웃 전체(복수면 클라이언트가 대표 호스트 선택 picker 를 띄운다). // ResponseHelper::success 는 Resource additional() 을 보존하지 않으므로 data 안에 병합. $payload = (new LayoutExtensionResource($extension))->resolve(); $payload['host_layouts'] = $this->layoutExtensionService->getExtensionHostLayouts($extension); return $this->success('common.success', $payload); } /** * 레이아웃 확장 content 수정 * * @param UpdateLayoutExtensionContentRequest $request 검증된 요청 * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @return JsonResponse 업데이트된 확장 응답 */ public function update(UpdateLayoutExtensionContentRequest $request, string $templateName, int $extensionId): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } try { DB::beginTransaction(); // content 는 input() 으로 전체 배열을 전달한다. // validated() 는 content.* 하위 규칙이 있을 경우 하위 키만 재구성하여 // extension_point/components 등 최상위 키가 누락되기 때문이다. $data = [ 'content' => $request->input('content'), ]; if ($request->has('priority')) { $data['priority'] = $request->input('priority'); } if ($request->has('expected_lock_version')) { $data['expected_lock_version'] = $request->input('expected_lock_version'); } $updated = $this->layoutExtensionService->updateExtension($extensionId, $data); DB::commit(); return $this->success('common.success', new LayoutExtensionResource($updated)); } catch (ModelNotFoundException) { DB::rollBack(); return $this->notFound('common.not_found'); } catch (ConcurrentModificationException $e) { DB::rollBack(); return $this->error( 'exceptions.concurrent_modification', 409, [ 'error' => 'concurrent_modification', 'current_version' => $e->currentVersion, 'your_version' => $e->expectedVersion, 'resource' => $e->resource, ], ['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion], ); } catch (\Exception $e) { DB::rollBack(); return $this->error('common.failed', 500, ['error' => $e->getMessage()]); } } /** * 레이아웃 확장의 모든 버전 목록 조회 * * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @return JsonResponse 버전 목록 응답 */ public function versions(string $templateName, int $extensionId): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } $versions = $this->layoutExtensionService->getExtensionVersions($extensionId); return $this->success('common.success', LayoutExtensionVersionResource::collection($versions)); } /** * 특정 버전의 레이아웃 확장 content 조회 * * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @param int $version 버전 번호 * @return JsonResponse 특정 버전 응답 */ public function showVersion(string $templateName, int $extensionId, int $version): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } try { $extensionVersion = $this->layoutExtensionService->getExtensionVersion($extensionId, $version); } catch (ModelNotFoundException) { return $this->notFound('common.not_found'); } return $this->success('common.success', new LayoutExtensionVersionResource($extensionVersion)); } /** * 레이아웃 확장 버전 복원 * * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @param int $versionId 복원할 버전 ID * @return JsonResponse 복원 후 새 버전 응답 */ public function restoreVersion(string $templateName, int $extensionId, int $versionId): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } try { DB::beginTransaction(); $newVersion = $this->layoutExtensionService->restoreExtensionVersion($extensionId, $versionId); DB::commit(); return $this->success('common.success', new LayoutExtensionVersionResource($newVersion)); } catch (ModelNotFoundException) { DB::rollBack(); return $this->notFound('common.not_found'); } catch (\Exception $e) { DB::rollBack(); return $this->error('common.failed', 500, ['error' => $e->getMessage()]); } } /** * 레이아웃 확장 미리보기 생성 * * 편집 중인 확장 content를 임시 저장하고, 대표 레이아웃에 적용한 미리보기 URL을 반환합니다. * * @param StoreLayoutExtensionPreviewRequest $request 검증된 요청 * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @return JsonResponse 미리보기 토큰 응답 */ public function storePreview(StoreLayoutExtensionPreviewRequest $request, string $templateName, int $extensionId): JsonResponse { $extension = $this->resolveExtension($templateName, $extensionId); if (! $extension) { return $this->notFound('common.not_found'); } // 대표 레이아웃 결정: // - overlay 타입은 target_name 자체가 대표 레이아웃 // - extension_point 타입은 프론트가 preview_layout 으로 전달 $previewLayout = $request->validated('preview_layout'); if (! $previewLayout) { $previewLayout = $extension->extension_type->value === 'overlay' ? $extension->target_name : null; } if (! $previewLayout) { return $this->error('validation.layout_extension.preview_layout.required', 422); } try { $preview = $this->layoutPreviewService->createExtensionPreview( $extension->template_id, $extensionId, $previewLayout, $request->validated('content'), $request->user()->id ); return $this->success('common.success', [ 'token' => $preview->token, 'preview_url' => '/preview/'.$preview->token, 'expires_at' => $preview->expires_at->toIso8601String(), ]); } catch (\Exception $e) { return $this->error('common.failed', 500, ['error' => $e->getMessage()]); } } /** * 템플릿 식별자와 확장 ID 로 확장을 조회하고, 템플릿 소속을 교차검증합니다. * * @param string $templateName 템플릿 identifier * @param int $extensionId 확장 ID * @return LayoutExtension|null 확장 모델 또는 null (템플릿 미존재 / 확장 미존재 / 소속 불일치) */ private function resolveExtension(string $templateName, int $extensionId): ?LayoutExtension { $template = $this->templateService->findByIdentifier($templateName); if (! $template) { return null; } try { $extension = $this->layoutExtensionService->getExtensionById($extensionId); } catch (ModelNotFoundException) { return null; } // 확장이 요청 템플릿에 속하는지 교차검증 if ($extension->template_id !== $template->id) { return null; } return $extension; } }