diff --git a/modules/_bundled/sirsoft-board/config/settings/defaults.json b/modules/_bundled/sirsoft-board/config/settings/defaults.json index f7e2e7dc..65141d13 100644 --- a/modules/_bundled/sirsoft-board/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-board/config/settings/defaults.json @@ -19,6 +19,8 @@ "comment_order": "ASC", "show_view_count": true, "use_report": false, + "use_reaction": true, + "active_reaction_types": ["like"], "min_title_length": 2, "max_title_length": 200, "min_content_length": 2, diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000001_create_board_reaction_types_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000001_create_board_reaction_types_table.php new file mode 100644 index 00000000..3e78700c --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000001_create_board_reaction_types_table.php @@ -0,0 +1,46 @@ +id()->comment('반응 유형 ID'); + $table->string('code', 50)->unique()->comment('내부 식별자 (예: like, dislike)'); + $table->text('name')->comment('다국어 라벨 JSON ({"ko":"추천","en":"Recommend","ja":"おすすめ"})'); + $table->string('icon')->nullable()->comment('Font Awesome 아이콘 클래스 (예: fas fa-thumbs-up)'); + $table->unsignedInteger('display_order')->default(0)->comment('표시 순서'); + $table->boolean('is_active')->default(true)->comment('활성 여부 (완전 삭제 미지원, 비활성화만 가능)'); + $table->text('user_overrides')->nullable()->comment('사용자 수정 보존 필드 (언어팩 시드 머지 시 사용자 편집값 유지)'); + $table->timestamps(); + + $table->index('is_active', 'idx_reaction_type_active'); + $table->index('display_order', 'idx_reaction_type_order'); + }); + + if (DB::getDriverName() == 'mysql') { + Schema::table('board_reaction_types', function (Blueprint $table) { + $table->comment('게시판 반응 유형 (추천/비추천 등)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('board_reaction_types'); + } +}; diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000002_create_board_reactions_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000002_create_board_reactions_table.php new file mode 100644 index 00000000..2a6bbcbc --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000002_create_board_reactions_table.php @@ -0,0 +1,52 @@ +id()->comment('반응 이력 ID'); + $table->unsignedBigInteger('user_id')->comment('반응한 사용자 ID'); + $table->string('target_type', 20)->comment('반응 대상 타입 (현재 post, 향후 comment 확장 가능)'); + $table->unsignedBigInteger('target_id')->comment('반응 대상 ID (동적 테이블 ID, FK 없이 앱 레벨 무결성 관리)'); + $table->unsignedBigInteger('reaction_type_id')->comment('반응 유형 ID'); + $table->unsignedBigInteger('board_id')->nullable()->comment('게시판 ID (게시판 삭제 시 NULL)'); + $table->timestamps(); + + $table->unique(['user_id', 'target_type', 'target_id'], 'unique_user_target_reaction'); + $table->index(['target_type', 'target_id'], 'idx_reaction_target'); + $table->index('reaction_type_id', 'idx_reaction_type'); + $table->index('board_id', 'idx_reaction_board'); + + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->foreign('reaction_type_id')->references('id')->on('board_reaction_types')->restrictOnDelete(); + $table->foreign('board_id')->references('id')->on('boards')->nullOnDelete(); + }); + + if (DB::getDriverName() == 'mysql') { + Schema::table('board_reactions', function (Blueprint $table) { + $table->comment('게시판 반응 이력 (사용자+대상당 1행)'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('board_reactions'); + } +}; diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000003_add_use_reaction_to_boards_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000003_add_use_reaction_to_boards_table.php new file mode 100644 index 00000000..ad4f71c4 --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000003_add_use_reaction_to_boards_table.php @@ -0,0 +1,41 @@ +boolean('use_reaction') + ->default(true) + ->after('use_report') + ->comment('반응(추천/비추천) 사용 여부'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('boards')) { + $columns = Schema::getColumnListing('boards'); + + Schema::table('boards', function (Blueprint $table) use ($columns) { + if (in_array('use_reaction', $columns)) { + $table->dropColumn('use_reaction'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000004_add_active_reaction_types_to_boards_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000004_add_active_reaction_types_to_boards_table.php new file mode 100644 index 00000000..e099050c --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000004_add_active_reaction_types_to_boards_table.php @@ -0,0 +1,43 @@ +text('active_reaction_types') + ->nullable() + ->after('use_reaction') + ->comment('활성화된 반응 유형 code 목록 JSON 배열 (예: ["like","dislike"])'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('boards')) { + $columns = Schema::getColumnListing('boards'); + + Schema::table('boards', function (Blueprint $table) use ($columns) { + if (in_array('active_reaction_types', $columns)) { + $table->dropColumn('active_reaction_types'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000005_add_reaction_counts_to_board_posts_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000005_add_reaction_counts_to_board_posts_table.php new file mode 100644 index 00000000..d64652cb --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_04_000005_add_reaction_counts_to_board_posts_table.php @@ -0,0 +1,43 @@ +text('reaction_counts') + ->nullable() + ->after('attachments_count') + ->comment('반응 유형별 개수 JSON (키는 유형 ID, 예: {"1":18,"2":2})'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('board_posts')) { + $columns = Schema::getColumnListing('board_posts'); + + Schema::table('board_posts', function (Blueprint $table) use ($columns) { + if (in_array('reaction_counts', $columns)) { + $table->dropColumn('reaction_counts'); + } + }); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/database/seeders/BoardReactionTypeSeeder.php b/modules/_bundled/sirsoft-board/database/seeders/BoardReactionTypeSeeder.php new file mode 100644 index 00000000..c664f97d --- /dev/null +++ b/modules/_bundled/sirsoft-board/database/seeders/BoardReactionTypeSeeder.php @@ -0,0 +1,90 @@ +> + */ + public function getDefaults(): array + { + return [ + [ + 'code' => 'like', + 'name' => ['ko' => '추천', 'en' => 'Recommend', 'ja' => 'おすすめ'], + 'icon' => 'fas fa-thumbs-up', + 'display_order' => 1, + 'is_active' => true, + ], + [ + 'code' => 'dislike', + 'name' => ['ko' => '비추천', 'en' => 'Not Recommend', 'ja' => 'ひどい'], + 'icon' => 'fas fa-thumbs-down', + 'display_order' => 2, + 'is_active' => true, + ], + ]; + } + + /** + * 시더 실행 + */ + public function run(): void + { + $helper = app(GenericEntitySyncHelper::class); + + foreach ($this->resolveTranslatedDefaults() as $reactionType) { + $existing = ReactionType::where('code', $reactionType['code'])->exists(); + + $helper->sync( + ReactionType::class, + ['code' => $reactionType['code']], + [ + 'name' => $reactionType['name'], + 'icon' => $reactionType['icon'], + 'display_order' => $reactionType['display_order'], + 'is_active' => $reactionType['is_active'], + ], + ); + + if ($existing) { + $this->command->info(" 반응 유형 '{$reactionType['code']}' 동기화 (사용자 수정 보존)."); + } else { + $this->command->info(" 반응 유형 '{$reactionType['code']}' 생성 완료."); + } + } + } +} diff --git a/modules/_bundled/sirsoft-board/database/seeders/DatabaseSeeder.php b/modules/_bundled/sirsoft-board/database/seeders/DatabaseSeeder.php index 1057d8b1..45e95cea 100644 --- a/modules/_bundled/sirsoft-board/database/seeders/DatabaseSeeder.php +++ b/modules/_bundled/sirsoft-board/database/seeders/DatabaseSeeder.php @@ -44,9 +44,10 @@ class DatabaseSeeder extends Seeder $this->command->info(''); // 설치 필수 시더 (항상 실행) - $this->command->info('[설치] 게시판 타입 생성'); + $this->command->info('[설치] 게시판 타입 / 반응 유형 생성'); $this->call([ BoardTypeSeeder::class, + BoardReactionTypeSeeder::class, ]); $this->command->info(''); diff --git a/modules/_bundled/sirsoft-board/docs/api/README.md b/modules/_bundled/sirsoft-board/docs/api/README.md index bdead860..52b50c20 100644 --- a/modules/_bundled/sirsoft-board/docs/api/README.md +++ b/modules/_bundled/sirsoft-board/docs/api/README.md @@ -15,6 +15,7 @@ | [boards.md](boards.md) | `boards` | 37 | | [dashboard.md](dashboard.md) | `dashboard` | 4 | | [my-comments.md](my-comments.md) | `my-comments` | 1 | +| [reaction.md](reaction.md) | `reaction` | 2 | | [reports.md](reports.md) | `reports` | 7 | | [settings.md](settings.md) | `settings` | 5 | | [users.md](users.md) | `users` | 2 | diff --git a/modules/_bundled/sirsoft-board/docs/api/reaction.md b/modules/_bundled/sirsoft-board/docs/api/reaction.md new file mode 100644 index 00000000..0f5571c2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/docs/api/reaction.md @@ -0,0 +1,134 @@ +# Reaction API 레퍼런스 + +> **소유**: module `sirsoft-board` · 게시글 반응(추천/비추천) 관련 엔드포인트 레퍼런스입니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 게시글 반응(추천/비추천) 등록·전환·해제를 단일 POST 엔드포인트로 처리합니다 +2. 반응은 로그인 회원만 가능하며(auth:sanctum), 본인 글에는 반응할 수 없습니다 +3. 글당 반응 1개 — 같은 유형 재요청은 해제, 다른 유형은 전환(이전 -1·신규 +1) +4. 관리자 반응 유형 목록 API는 게시판 설정 화면의 유형 체크박스 옵션 소스입니다 +5. 유형 CRUD 는 이번 범위 밖 — 유형은 시더/스크립트로 관리합니다 +``` + +--- + +## POST /api/modules/sirsoft-board/boards/{slug}/posts/{postId}/react + +게시글에 반응(추천/비추천)을 남깁니다. 등록·전환·해제를 한 엔드포인트가 통합 처리합니다. + +- **라우트명**: `api.modules.sirsoft-board.boards.posts.react` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\User\ReactionController@react` +- **인증/권한**: `auth:sanctum` (로그인 회원 전용) + +**경로 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 용도 | +| --- | --- | --- | --- | --- | +| slug | path | string | 예 | 게시판 slug | +| postId | path | integer | 예 | 반응 대상 게시글 ID (해당 게시판 소속이어야 함) | + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 용도 | +| --- | --- | --- | --- | --- | +| reaction_type_id | body | integer | 예 | 반응 유형 ID. 존재하는 활성 유형이면서 게시판이 켠(활성) 유형이어야 합니다. | + +**요청 예시** + +```http +POST /api/modules/sirsoft-board/boards/free/posts/42/react HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "reaction_type_id": 1 +} +``` + +**동작** + +| 상황 | 결과 (`data.action`) | 카운트 변화 | +| --- | --- | --- | +| 기존 반응 없음 | `add` | 해당 유형 +1 | +| 기존 반응이 다른 유형 | `change` | 이전 유형 -1 · 신규 유형 +1 | +| 기존 반응이 같은 유형 | `remove` | 해당 유형 -1 (이력 행 삭제) | + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| action | string | `add` | 수행된 동작 (`add`/`change`/`remove`) | +| my_reaction_type_id | integer\|null | `1` | 처리 후 내가 누른 반응 유형 ID. 해제 시 `null`. | +| reaction_counts | object | `{"1":18,"2":2}` | 게시판이 켠 활성 유형 전체의 개수 맵. 키는 유형 ID 문자열, 개수 0인 유형도 포함됩니다. | + +**응답 예시** + +```json +{ + "success": true, + "message": "반응을 남겼습니다.", + "data": { + "action": "add", + "my_reaction_type_id": 1, + "reaction_counts": { "1": 18, "2": 2 } + } +} +``` + +**오류** + +| 상태 | 사유 | +| --- | --- | +| 401 | 비로그인 요청 | +| 404 | 게시글이 해당 게시판 소속이 아니거나 존재하지 않음 | +| 422 | 반응 기능이 꺼진 게시판 / 게시판이 켜지 않은 유형 / 본인 글 반응 / `reaction_type_id` 검증 실패 | + +--- + +## GET /api/modules/sirsoft-board/admin/reaction-types + +활성 반응 유형 전체를 `display_order` 순으로 반환합니다. 게시판 설정 화면의 "사용할 반응 유형" 체크박스 옵션 소스입니다. 유형 CRUD 는 제공하지 않습니다. + +- **라우트명**: `api.modules.sirsoft-board.admin.reaction-types.index` +- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\ReactionTypeController@index` +- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-board.settings.read` + +**요청 예시** + +```http +GET /api/modules/sirsoft-board/admin/reaction-types HTTP/1.1 +Host: api.example.com +Accept: application/json +Accept-Language: ko +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data.reaction_types[]` 배열 항목) + +| 필드 | 타입 | 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| id | integer | `1` | 반응 유형 ID | +| code | string | `like` | 내부 식별자 (예: `like`/`dislike`) | +| name | string | `추천` | 현재 로케일 라벨. 미입력 언어는 사이트 기본 언어로 폴백됩니다. | +| icon | string\|null | `fas fa-thumbs-up` | Font Awesome 아이콘 클래스 | + +**응답 예시** + +```json +{ + "success": true, + "message": "반응 유형 목록을 조회했습니다.", + "data": { + "reaction_types": [ + { "id": 1, "code": "like", "name": "추천", "icon": "fas fa-thumbs-up" }, + { "id": 2, "code": "dislike", "name": "비추천", "icon": "fas fa-thumbs-down" } + ] + } +} +``` diff --git a/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php b/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php new file mode 100644 index 00000000..243eda3b --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Exceptions/ReactionNotAllowedException.php @@ -0,0 +1,81 @@ +reason; + } + + /** + * 컨트롤러 catch 와 동일한 422 응답으로 렌더링합니다. + * + * @return JsonResponse 반응 불가 응답 + */ + public function render(): JsonResponse + { + return ResponseHelper::error($this->getMessage(), 422, ['code' => 'reaction_not_allowed']); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReactionTypeController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReactionTypeController.php new file mode 100644 index 00000000..17a416e6 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/Admin/ReactionTypeController.php @@ -0,0 +1,50 @@ +reactionTypeRepository->getActive(); + + return $this->success( + 'sirsoft-board::messages.reaction.list_success', + ['reaction_types' => ReactionTypeResource::collection($types)] + ); + } catch (\Exception $e) { + Log::error('반응 유형 목록 조회 실패', ['error' => $e->getMessage()]); + + return $this->error('sirsoft-board::messages.reaction.failed', 500); + } + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Controllers/User/ReactionController.php b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/ReactionController.php new file mode 100644 index 00000000..300efd02 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Controllers/User/ReactionController.php @@ -0,0 +1,77 @@ +boardService->getBoardBySlug($slug, checkScope: false); + + if (! $board) { + return $this->notFound('sirsoft-board::messages.boards.error_404'); + } + + $result = $this->reactionService->react( + (int) Auth::id(), + $board, + $postId, + (int) $request->validated('reaction_type_id'), + ); + + $messageKey = match ($result['action']) { + 'change' => 'sirsoft-board::messages.reaction.change_success', + 'remove' => 'sirsoft-board::messages.reaction.remove_success', + default => 'sirsoft-board::messages.reaction.add_success', + }; + + return $this->success($messageKey, [ + 'action' => $result['action'], + 'my_reaction_type_id' => $result['reaction_type_id'], + 'reaction_counts' => $result['reaction_counts'], + ]); + } catch (PostNotFoundException $e) { + return $this->notFound('sirsoft-board::messages.post.not_found'); + } catch (ReactionNotAllowedException $e) { + return $this->error($e->getMessage(), 422, ['code' => 'reaction_not_allowed']); + } catch (\Exception $e) { + return $this->error('sirsoft-board::messages.reaction.failed', 500, $e->getMessage()); + } + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/ReactPostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/ReactPostRequest.php new file mode 100644 index 00000000..eb87bb20 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/ReactPostRequest.php @@ -0,0 +1,68 @@ + + */ + public function rules(): array + { + $slug = (string) ($this->route('slug') ?? ''); + $board = app(BoardRepositoryInterface::class)->findBySlug($slug); + $activeCodes = $board?->active_reaction_types ?? []; + + return [ + 'reaction_type_id' => ['required', 'integer', new AvailableReactionType($activeCodes)], + ]; + } + + /** + * 검증 오류 메시지 커스터마이징 + * + * @return array + */ + public function messages(): array + { + return [ + 'reaction_type_id.required' => __('sirsoft-board::validation.reaction.reaction_type_id.required'), + 'reaction_type_id.integer' => __('sirsoft-board::validation.reaction.reaction_type_id.integer'), + ]; + } + + /** + * 검증할 필드의 이름을 커스터마이징 + * + * @return array + */ + public function attributes(): array + { + return [ + 'reaction_type_id' => __('sirsoft-board::validation.attributes.reaction.reaction_type_id'), + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php index e1e997b5..0c8b93b2 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/BoardResource.php @@ -57,6 +57,9 @@ class BoardResource extends BaseApiResource 'use_reply' => $this->use_reply, 'max_reply_depth' => $this->max_reply_depth, 'use_report' => $this->use_report, + 'use_reaction' => $this->use_reaction, + 'active_reaction_types' => $this->active_reaction_types ?? [], + 'reaction_type_options' => self::buildReactionTypeOptions($this->active_reaction_types ?? []), 'comment_order' => $this->comment_order, 'max_comment_depth' => $this->max_comment_depth, @@ -266,6 +269,35 @@ class BoardResource extends BaseApiResource return $this->blocked_keywords ?? []; } + /** + * 게시판이 켠(활성) 반응 유형의 옵션 배열을 만듭니다. + * + * DB 컬럼 `active_reaction_types`(code 문자열 배열)와 `board_reaction_types` 를 + * 조인해 프론트가 바로 쓸 수 있는 `{id, code, name, icon}` 배열로 변환합니다. + * 게시판이 켠 유형 중 시스템에서 살아있는(활성) 유형만, display_order 순으로 노출합니다 + * (이슈 #525 확정 11 — 게시판 ∩ 시스템). 응답 필드명을 DB 컬럼과 의도적으로 달리해 + * 동명이의어를 방지합니다. + * + * @param array $activeCodes 게시판이 켠 반응 유형 code 목록 + * @return array> 반응 유형 옵션 배열 + */ + public static function buildReactionTypeOptions(array $activeCodes): array + { + if (empty($activeCodes)) { + return []; + } + + $types = app(\Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface::class) + ->findByCodes($activeCodes); + + return $types->map(fn ($type) => [ + 'id' => $type->id, + 'code' => $type->code, + 'name' => $type->getLocalizedName(), + 'icon' => $type->icon, + ])->values()->all(); + } + /** * 허용 확장자를 배열로 반환합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php index e50baa6b..4f5bbb9a 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Enums\ReportReasonType; use Modules\Sirsoft\Board\Enums\TriggerType; +use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface; use Modules\Sirsoft\Board\Support\BoardPermissionCacheKeys; use Modules\Sirsoft\Board\Traits\ChecksBoardPermission; @@ -77,6 +78,12 @@ class PostResource extends BaseApiResource // 상세 전용: 신고 여부 (로그인 사용자 + board 관계 로드 시에만) 'is_already_reported' => $this->getIsAlreadyReported($request), + // 상세 전용: 반응 카운트 (활성 유형 전체 항상 포함 — 확정 09, 0도 노출) + 'reaction_counts' => $this->getReactionCountsForResponse(), + + // 상세 전용: 내가 누른 반응 유형 ID (로그인 사용자 + board 관계 로드 시에만, null 가능) + 'my_reaction_type_id' => $this->getMyReactionTypeId($request), + // 권한 정보 (is_owner + abilities) — 상세 페이지에서만 포함 ...$this->resourceMeta($request), ]; @@ -341,6 +348,8 @@ class PostResource extends BaseApiResource 'use_comment' => $this->board->use_comment, 'use_reply' => $this->board->use_reply, 'use_report' => $this->board->use_report, + 'use_reaction' => $this->board->use_reaction, + 'reaction_type_options' => BoardResource::buildReactionTypeOptions($this->board->active_reaction_types ?? []), 'show_view_count' => $this->board->show_view_count, 'max_reply_depth' => $this->board->max_reply_depth ?? g7_module_settings('sirsoft-board', 'basic_defaults.max_reply_depth', 5), 'max_comment_depth' => $this->board->max_comment_depth ?? g7_module_settings('sirsoft-board', 'basic_defaults.max_comment_depth', 10), @@ -480,6 +489,53 @@ class PostResource extends BaseApiResource ->hasUserReported($user->id, $this->board->id, 'post', $this->id); } + /** + * 반응 카운트를 응답용으로 반환합니다. + * + * 게시판이 켠(활성) 유형 전체를 키로 항상 포함하며, 저장된 카운트가 없는 유형은 + * 0 으로 채웁니다 (확정 09 — 활성 유형은 개수 0이어도 항상 노출). 키는 유형 ID 문자열. + * + * @return array 유형 ID 문자열 => 개수 + */ + private function getReactionCountsForResponse(): array + { + if (! $this->relationLoaded('board') || ! $this->board) { + return []; + } + + $stored = $this->reaction_counts ?? []; + $options = BoardResource::buildReactionTypeOptions($this->board->active_reaction_types ?? []); + + $counts = []; + foreach ($options as $option) { + $key = (string) $option['id']; + $counts[$key] = (int) ($stored[$key] ?? 0); + } + + return $counts; + } + + /** + * 로그인 사용자가 이 게시글에 남긴 반응 유형 ID 를 반환합니다. + * + * 로그인 사용자 + board 관계 로드 시에만 조회하며, 반응이 없으면 null 을 반환합니다. + * + * @param Request $request HTTP 요청 + * @return int|null 내가 누른 반응 유형 ID (없으면 null) + */ + private function getMyReactionTypeId(Request $request): ?int + { + $user = $request->user(); + if (! $user || ! $this->relationLoaded('board') || ! $this->board) { + return null; + } + + $reaction = app(ReactionRepositoryInterface::class) + ->findByUserAndTarget($user->id, 'post', $this->id); + + return $reaction?->reaction_type_id; + } + // ========================================================================= // 상세 페이지 전용: 조건부 필드 메서드 // ========================================================================= diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/ReactionTypeResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/ReactionTypeResource.php new file mode 100644 index 00000000..fe0566a3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/ReactionTypeResource.php @@ -0,0 +1,29 @@ + 변환된 배열 데이터 + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'code' => $this->code, + 'name' => $this->getLocalizedName(), + 'icon' => $this->icon, + ]; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php b/modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php index 560ebf51..7faccf1a 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/BoardActivityLogListener.php @@ -12,6 +12,7 @@ use Modules\Sirsoft\Board\Models\BoardType; use Modules\Sirsoft\Board\Models\Comment; use Modules\Sirsoft\Board\Models\Post; use Modules\Sirsoft\Board\Models\Report; +use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface; /** @@ -29,9 +30,11 @@ class BoardActivityLogListener implements HookListenerInterface /** * @param ReportRepositoryInterface $reportRepository 신고 bulk lookup + * @param ReactionTypeRepositoryInterface $reactionTypeRepository 반응 유형 라벨 조회 */ public function __construct( protected ReportRepositoryInterface $reportRepository, + protected ReactionTypeRepositoryInterface $reactionTypeRepository, ) {} /** @@ -83,6 +86,9 @@ class BoardActivityLogListener implements HookListenerInterface 'sirsoft-board.report.after_restore_content' => ['method' => 'handleReportAfterRestoreContent', 'priority' => 20], 'sirsoft-board.report.after_blind_content' => ['method' => 'handleReportAfterBlindContent', 'priority' => 20], 'sirsoft-board.report.after_delete_content' => ['method' => 'handleReportAfterDeleteContent', 'priority' => 20], + + // ─── Reaction ─── + 'sirsoft-board.reaction.after_react' => ['method' => 'handleReactionAfterReact', 'priority' => 20], ]; } @@ -715,4 +721,52 @@ class BoardActivityLogListener implements HookListenerInterface 'description_params' => ['report_id' => $report->id], ]); } + + // ═══════════════════════════════════════════ + // Reaction 핸들러 + // ═══════════════════════════════════════════ + + /** + * 반응 등록/전환/해제 후 로그 기록 + * + * @param int $userId 반응한 사용자 ID + * @param Post $post 대상 게시글 + * @param int $reactionTypeId 반응 유형 ID + * @param string $action 수행된 동작 (add | change | remove) + */ + public function handleReactionAfterReact(int $userId, Post $post, int $reactionTypeId, string $action): void + { + $post->loadMissing('board'); + + $reactionType = $this->reactionTypeRepository->findById($reactionTypeId); + $typeName = $reactionType?->getLocalizedName() ?? ''; + + $actionKey = match ($action) { + 'change' => 'reaction.change', + 'remove' => 'reaction.remove', + default => 'reaction.add', + }; + + $descriptionKey = match ($action) { + 'change' => 'sirsoft-board::activity_log.description.reaction_change', + 'remove' => 'sirsoft-board::activity_log.description.reaction_remove', + default => 'sirsoft-board::activity_log.description.reaction_add', + }; + + $this->logActivity($actionKey, [ + 'loggable' => $post, + 'description_key' => $descriptionKey, + 'description_params' => [ + 'title' => $post->title ?? '', + 'reaction_type' => $typeName, + 'board_name' => $post->board?->name ?? '', + ], + 'properties' => [ + 'title' => $post->title, + 'reaction_type_id' => $reactionTypeId, + 'reaction_type' => $typeName, + 'board_name' => $post->board?->name ?? '', + ], + ]); + } } diff --git a/modules/_bundled/sirsoft-board/src/Models/Board.php b/modules/_bundled/sirsoft-board/src/Models/Board.php index 0e0afef0..ed3d7d3c 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Board.php +++ b/modules/_bundled/sirsoft-board/src/Models/Board.php @@ -127,6 +127,8 @@ class Board extends Model 'use_reply', 'max_reply_depth', 'use_report', + 'use_reaction', + 'active_reaction_types', 'new_display_hours', // 입력 제한 설정 @@ -183,6 +185,9 @@ class Board extends Model // Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다. 'new_display_hours' => 'integer', 'use_report' => 'boolean', + 'use_reaction' => 'boolean', + // 활성 반응 유형 code 목록 (문자열 배열, 예: ["like","dislike"]) + 'active_reaction_types' => 'array', 'use_file_upload' => 'boolean', 'notify_author' => 'boolean', 'notify_admin_on_post' => 'boolean', diff --git a/modules/_bundled/sirsoft-board/src/Models/Post.php b/modules/_bundled/sirsoft-board/src/Models/Post.php index 3c4cca20..0d0d5402 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Post.php +++ b/modules/_bundled/sirsoft-board/src/Models/Post.php @@ -79,6 +79,7 @@ class Post extends Model implements FulltextSearchable 'replies_count', 'comments_count', 'attachments_count', + 'reaction_counts', ]; /** @@ -99,6 +100,8 @@ class Post extends Model implements FulltextSearchable 'replies_count' => 'integer', 'comments_count' => 'integer', 'attachments_count' => 'integer', + // 반응 유형별 개수 (키는 유형 ID 문자열, 예: {"1":18,"2":2}) + 'reaction_counts' => 'array', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'deleted_at' => 'datetime', @@ -107,6 +110,8 @@ class Post extends Model implements FulltextSearchable /** * 게시판과의 관계를 정의합니다. + * + * @return BelongsTo */ public function board(): BelongsTo { @@ -115,6 +120,8 @@ class Post extends Model implements FulltextSearchable /** * 작성자와의 관계를 정의합니다 (회원). + * + * @return BelongsTo */ public function user(): BelongsTo { @@ -123,6 +130,8 @@ class Post extends Model implements FulltextSearchable /** * 부모 게시글과의 관계를 정의합니다 (답글용). + * + * @return BelongsTo */ public function parent(): BelongsTo { @@ -134,6 +143,8 @@ class Post extends Model implements FulltextSearchable * * board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고 * Repository의 Eager loading 클로저에서 명시적으로 전달합니다. + * + * @return HasMany */ public function replies(): HasMany { @@ -145,6 +156,8 @@ class Post extends Model implements FulltextSearchable * * board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고 * Repository의 Eager loading 클로저에서 명시적으로 전달합니다. + * + * @return HasMany */ public function comments(): HasMany { @@ -157,6 +170,8 @@ class Post extends Model implements FulltextSearchable * * board_id 조건은 Eager loading 호환을 위해 관계에서 제외하고 * Repository의 Eager loading 클로저에서 명시적으로 전달합니다. + * + * @return HasMany */ public function attachments(): HasMany { diff --git a/modules/_bundled/sirsoft-board/src/Models/Reaction.php b/modules/_bundled/sirsoft-board/src/Models/Reaction.php new file mode 100644 index 00000000..0173fe40 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Models/Reaction.php @@ -0,0 +1,89 @@ + 'integer', + 'target_id' => 'integer', + 'reaction_type_id' => 'integer', + 'board_id' => 'integer', + ]; + } + + /** + * 반응한 사용자와의 관계. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 반응 유형과의 관계. + * + * @return BelongsTo + */ + public function reactionType(): BelongsTo + { + return $this->belongsTo(ReactionType::class, 'reaction_type_id'); + } + + /** + * 게시판과의 관계 (nullable). + * + * @return BelongsTo + */ + public function board(): BelongsTo + { + return $this->belongsTo(Board::class, 'board_id'); + } + + /** + * 특정 대상(타입+ID)의 반응만 조회하는 스코프. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $targetType 대상 타입 (예: post) + * @param int $targetId 대상 ID + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeByTarget($query, string $targetType, int $targetId) + { + return $query->where('target_type', $targetType) + ->where('target_id', $targetId); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Models/ReactionType.php b/modules/_bundled/sirsoft-board/src/Models/ReactionType.php new file mode 100644 index 00000000..56ae6a81 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Models/ReactionType.php @@ -0,0 +1,94 @@ + + */ + protected array $trackableFields = ['name', 'icon']; + + /** + * 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존. + * + * @var array + */ + protected array $translatableTrackableFields = ['name']; + + protected function casts(): array + { + return [ + 'name' => AsUnicodeJson::class, + 'display_order' => 'integer', + 'is_active' => 'boolean', + 'user_overrides' => 'array', + ]; + } + + /** + * 지정된 로케일의 반응 유형명 반환 (미입력 언어는 폴백). + * + * @param string|null $locale 로케일 (null이면 현재 로케일) + * @return string 해당 로케일의 유형명 + */ + public function getLocalizedName(?string $locale = null): string + { + $locale = $locale ?? app()->getLocale(); + + if (! is_array($this->name)) { + return (string) $this->name; + } + + return $this->name[$locale] + ?? $this->name[config('app.fallback_locale')] + ?? (! empty($this->name) ? array_values($this->name)[0] : '') + ?? ''; + } + + /** + * 활성 유형만 조회하는 스코프. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php index cc2e521a..7b462972 100644 --- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php @@ -16,9 +16,13 @@ use Modules\Sirsoft\Board\Repositories\Contracts\BoardStatRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\BoardTypeRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\CommentRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface; +use Modules\Sirsoft\Board\Repositories\Contracts\ReactionRepositoryInterface; +use Modules\Sirsoft\Board\Repositories\Contracts\ReactionTypeRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\UserNotificationSettingRepositoryInterface; use Modules\Sirsoft\Board\Repositories\PostRepository; +use Modules\Sirsoft\Board\Repositories\ReactionRepository; +use Modules\Sirsoft\Board\Repositories\ReactionTypeRepository; use Modules\Sirsoft\Board\Repositories\ReportRepository; use Modules\Sirsoft\Board\Repositories\UserNotificationSettingRepository; use Modules\Sirsoft\Board\Seo\BoardSitemapContributor; @@ -77,6 +81,8 @@ class BoardServiceProvider extends BaseModuleServiceProvider BoardTypeRepositoryInterface::class => BoardTypeRepository::class, CommentRepositoryInterface::class => CommentRepository::class, PostRepositoryInterface::class => PostRepository::class, + ReactionRepositoryInterface::class => ReactionRepository::class, + ReactionTypeRepositoryInterface::class => ReactionTypeRepository::class, ReportRepositoryInterface::class => ReportRepository::class, UserNotificationSettingRepositoryInterface::class => UserNotificationSettingRepository::class, ]; diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionRepositoryInterface.php new file mode 100644 index 00000000..13e887b5 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionRepositoryInterface.php @@ -0,0 +1,54 @@ + $deltas 유형 ID => 증감값 (예: [1 => -1, 2 => 1]) + * @return array 갱신 후 reaction_counts (키는 유형 ID 문자열) + */ + public function adjustPostReactionCounts(int $postId, array $deltas): array; +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionTypeRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionTypeRepositoryInterface.php new file mode 100644 index 00000000..3c23f5cd --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/ReactionTypeRepositoryInterface.php @@ -0,0 +1,43 @@ + + */ + public function getActive(): Collection; + + /** + * code 목록으로 활성 반응 유형을 조회합니다 (display_order 순). + * + * @param array $codes 유형 code 목록 + * @return Collection + */ + public function findByCodes(array $codes): Collection; + + /** + * ID로 반응 유형을 조회합니다. + * + * @param int $id 유형 ID + * @return ReactionType|null 유형 모델 또는 null + */ + public function findById(int $id): ?ReactionType; + + /** + * ID 목록으로 반응 유형을 한 번에 조회합니다 (N+1 방지). + * + * @param array $ids 유형 ID 목록 + * @return Collection + */ + public function findByIds(array $ids): Collection; +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReactionRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReactionRepository.php new file mode 100644 index 00000000..66d52539 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Repositories/ReactionRepository.php @@ -0,0 +1,74 @@ +where('user_id', $userId) + ->byTarget($targetType, $targetId) + ->first(); + } + + /** + * {@inheritDoc} + */ + public function upsert(int $userId, string $targetType, int $targetId, int $reactionTypeId, ?int $boardId): Reaction + { + return Reaction::updateOrCreate( + [ + 'user_id' => $userId, + 'target_type' => $targetType, + 'target_id' => $targetId, + ], + [ + 'reaction_type_id' => $reactionTypeId, + 'board_id' => $boardId, + ], + ); + } + + /** + * {@inheritDoc} + */ + public function delete(Reaction $reaction): bool + { + return (bool) $reaction->delete(); + } + + /** + * {@inheritDoc} + */ + public function adjustPostReactionCounts(int $postId, array $deltas): array + { + /** @var Post $post */ + $post = Post::query()->lockForUpdate()->findOrFail($postId); + + $counts = $post->reaction_counts ?? []; + + foreach ($deltas as $typeId => $delta) { + $key = (string) $typeId; + $next = (int) ($counts[$key] ?? 0) + $delta; + $counts[$key] = max(0, $next); + } + + $post->reaction_counts = $counts; + $post->save(); + + return $counts; + } +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/ReactionTypeRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/ReactionTypeRepository.php new file mode 100644 index 00000000..4410355f --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Repositories/ReactionTypeRepository.php @@ -0,0 +1,65 @@ +where('is_active', true) + ->orderBy('display_order') + ->get(); + } + + /** + * {@inheritDoc} + */ + public function findByCodes(array $codes): Collection + { + if (empty($codes)) { + return ReactionType::query()->whereRaw('1 = 0')->get(); + } + + return ReactionType::query() + ->where('is_active', true) + ->whereIn('code', $codes) + ->orderBy('display_order') + ->get(); + } + + /** + * {@inheritDoc} + */ + public function findById(int $id): ?ReactionType + { + return ReactionType::query()->find($id); + } + + /** + * {@inheritDoc} + */ + public function findByIds(array $ids): Collection + { + if (empty($ids)) { + return ReactionType::query()->whereIn('id', [])->get(); + } + + return ReactionType::query() + ->whereIn('id', $ids) + ->orderBy('display_order') + ->get(); + } +} diff --git a/modules/_bundled/sirsoft-board/src/Rules/AvailableReactionType.php b/modules/_bundled/sirsoft-board/src/Rules/AvailableReactionType.php new file mode 100644 index 00000000..8bbe3955 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Rules/AvailableReactionType.php @@ -0,0 +1,49 @@ + $activeCodes 대상 게시판이 켠 반응 유형 code 목록 + */ + public function __construct( + private array $activeCodes = [] + ) {} + + /** + * 검증 규칙 실행 + * + * @param string $attribute 필드명 + * @param mixed $value 검증 값 (reaction_type_id) + * @param Closure $fail 실패 콜백 + */ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_numeric($value)) { + $fail(__('sirsoft-board::messages.reaction.inactive_type')); + + return; + } + + $type = app(ReactionTypeRepositoryInterface::class)->findById((int) $value); + + if ($type === null + || ! $type->is_active + || ! in_array($type->code, $this->activeCodes, true)) { + $fail(__('sirsoft-board::messages.reaction.inactive_type')); + } + } +} diff --git a/modules/_bundled/sirsoft-board/src/Services/ReactionService.php b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php new file mode 100644 index 00000000..b9bd9123 --- /dev/null +++ b/modules/_bundled/sirsoft-board/src/Services/ReactionService.php @@ -0,0 +1,153 @@ +} + * + * @throws PostNotFoundException 게시글이 이 게시판 소속이 아니거나 미존재 + * @throws ReactionNotAllowedException 반응 비활성 게시판 / 비활성 유형 / 본인 글 + */ + public function react(int $userId, Board $board, int $postId, int $reactionTypeId): array + { + // 게시글이 이 게시판 소속인지 스코프 검증 (교차 접근 차단) + $post = $this->postRepository->findByBoardId($board->id, $postId); + + if ($post === null) { + throw new PostNotFoundException($postId); + } + + // 반응 기능 off (확정 07 전제) + if (! $board->use_reaction) { + throw ReactionNotAllowedException::disabled(); + } + + // 본인 글 반응 차단 (확정 08) + if ((int) $post->user_id === $userId) { + throw ReactionNotAllowedException::selfPost(); + } + + // 요청 유형이 존재하는 활성 유형이면서 게시판이 켠 유형인지 확인 (확정 11) + $requestedType = $this->reactionTypeRepository->findById($reactionTypeId); + $activeCodes = $board->active_reaction_types ?? []; + + if ($requestedType === null + || ! $requestedType->is_active + || ! in_array($requestedType->code, $activeCodes, true)) { + throw ReactionNotAllowedException::inactiveType(); + } + + HookManager::doAction('sirsoft-board.reaction.before_react', $userId, $post, $reactionTypeId); + + $result = DB::transaction(function () use ($userId, $board, $post, $reactionTypeId) { + $existing = $this->reactionRepository->findByUserAndTarget( + $userId, + self::TARGET_POST, + $post->id, + ); + + // 같은 유형 재요청 → 해제 + if ($existing !== null && (int) $existing->reaction_type_id === $reactionTypeId) { + $this->reactionRepository->delete($existing); + $counts = $this->reactionRepository->adjustPostReactionCounts( + $post->id, + [$reactionTypeId => -1], + ); + + return ['action' => 'remove', 'reaction_type_id' => null, 'reaction_counts' => $counts]; + } + + // 다른 유형 → 전환 (이전 -1, 신규 +1) + if ($existing !== null) { + $previousTypeId = (int) $existing->reaction_type_id; + $this->reactionRepository->upsert( + $userId, + self::TARGET_POST, + $post->id, + $reactionTypeId, + $board->id, + ); + $counts = $this->reactionRepository->adjustPostReactionCounts( + $post->id, + [$previousTypeId => -1, $reactionTypeId => 1], + ); + + return ['action' => 'change', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts]; + } + + // 신규 등록 + $this->reactionRepository->upsert( + $userId, + self::TARGET_POST, + $post->id, + $reactionTypeId, + $board->id, + ); + $counts = $this->reactionRepository->adjustPostReactionCounts( + $post->id, + [$reactionTypeId => 1], + ); + + return ['action' => 'add', 'reaction_type_id' => $reactionTypeId, 'reaction_counts' => $counts]; + }); + + HookManager::doAction( + 'sirsoft-board.reaction.after_react', + $userId, + $post, + $reactionTypeId, + $result['action'], + ); + + return $result; + } +} diff --git a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php index d77bb3b6..4e1c7cfa 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/activity_log.php @@ -4,8 +4,10 @@ return [ // Action labels (last segment). // ActivityLog::getActionLabelAttribute resolves module-origin labels from the module's lang first. 'action' => [ + 'add' => 'Reaction Added', 'add_to_menu' => 'Added to Menu', 'blind' => 'Blinded', + 'change' => 'Reaction Changed', 'blind_content' => 'Content Blinded', 'bulk_apply' => 'Bulk Applied', 'bulk_apply_aborted' => 'Bulk Apply Aborted', @@ -14,6 +16,7 @@ return [ 'delete' => 'Deleted', 'delete_content' => 'Content Deleted', 'download' => 'Downloaded', + 'remove' => 'Reaction Removed', 'remove_from_menu' => 'Removed from Menu', 'restore' => 'Restored', 'restore_content' => 'Content Restored', @@ -69,6 +72,11 @@ return [ 'report_blind_content' => 'Report content blinded (ID: :report_id)', 'report_delete_content' => 'Report content deleted (ID: :report_id)', + // Reaction (recommend/not recommend) + 'reaction_add' => 'Reaction added (Board: :board_name, Title: :title, Type: :reaction_type)', + 'reaction_change' => 'Reaction changed (Board: :board_name, Title: :title, Type: :reaction_type)', + 'reaction_remove' => 'Reaction removed (Board: :board_name, Title: :title)', + // Board settings 'board_settings_index' => 'Board settings viewed', 'board_settings_bulk_apply' => 'Board settings bulk applied', diff --git a/modules/_bundled/sirsoft-board/src/lang/en/messages.php b/modules/_bundled/sirsoft-board/src/lang/en/messages.php index 925196f5..d2f7bb54 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/messages.php @@ -124,6 +124,19 @@ return [ 'post_deleted' => 'You cannot comment on a deleted post.', ], + // Reaction (recommend/not recommend) messages + 'reaction' => [ + 'list_success' => 'Reaction types have been retrieved.', + 'add_success' => 'Your reaction has been added.', + 'change_success' => 'Your reaction has been changed.', + 'remove_success' => 'Your reaction has been removed.', + 'failed' => 'Failed to process the reaction.', + 'disabled' => 'Reactions are disabled for this board.', + 'inactive_type' => 'This reaction type is not available on this board.', + 'self_post' => 'You cannot react to your own post.', + 'login_required' => 'You need to sign in to use this feature.', + ], + // Additional comment messages 'comments' => [ 'comments_disabled' => 'Comments are disabled for this board.', diff --git a/modules/_bundled/sirsoft-board/src/lang/en/validation.php b/modules/_bundled/sirsoft-board/src/lang/en/validation.php index ae4efe83..e8c1f0e5 100644 --- a/modules/_bundled/sirsoft-board/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/en/validation.php @@ -307,6 +307,8 @@ return [ 'basic_defaults.comment_order' => 'Comment Order', 'basic_defaults.show_view_count' => 'Show View Count', 'basic_defaults.use_report' => 'Use Report', + 'basic_defaults.use_reaction' => 'Use Reaction', + 'basic_defaults.active_reaction_types' => 'Active Reaction Types', 'basic_defaults.min_title_length' => 'Min Title Length', 'basic_defaults.max_title_length' => 'Max Title Length', 'basic_defaults.min_content_length' => 'Min Content Length', @@ -369,6 +371,9 @@ return [ 'process_note' => 'Process Note', 'ids' => 'Report IDs', ], + 'reaction' => [ + 'reaction_type_id' => 'Reaction Type', + ], 'blind' => [ 'reason' => 'Blind Reason', ], @@ -580,6 +585,12 @@ return [ ], // Report validation messages + 'reaction' => [ + 'reaction_type_id' => [ + 'required' => 'The reaction type is required.', + 'integer' => 'The reaction type must be an integer.', + ], + ], 'report' => [ 'invalid_status_transition' => 'The report cannot be changed to that status from its current status.', 'status' => [ diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php index c32f08b0..ed1cb77f 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/activity_log.php @@ -4,8 +4,10 @@ return [ // 액션 라벨 (마지막 세그먼트 기준). // ActivityLog::getActionLabelAttribute 가 모듈 origin 라벨을 자체 lang 에서 우선 조회. 'action' => [ + 'add' => '반응 등록', 'add_to_menu' => '메뉴 추가', 'blind' => '블라인드', + 'change' => '반응 변경', 'blind_content' => '콘텐츠 블라인드', 'bulk_apply' => '일괄 적용', 'bulk_apply_aborted' => '일괄 적용 중단', @@ -14,6 +16,7 @@ return [ 'delete' => '삭제', 'delete_content' => '콘텐츠 삭제', 'download' => '다운로드', + 'remove' => '반응 취소', 'remove_from_menu' => '메뉴 제거', 'restore' => '복원', 'restore_content' => '콘텐츠 복원', @@ -69,6 +72,11 @@ return [ 'report_blind_content' => '신고 콘텐츠 블라인드 (ID: :report_id)', 'report_delete_content' => '신고 콘텐츠 삭제 (ID: :report_id)', + // 반응 (추천/비추천) + 'reaction_add' => '반응 등록 (게시판: :board_name, 제목: :title, 유형: :reaction_type)', + 'reaction_change' => '반응 변경 (게시판: :board_name, 제목: :title, 유형: :reaction_type)', + 'reaction_remove' => '반응 취소 (게시판: :board_name, 제목: :title)', + // 게시판 설정 'board_settings_index' => '게시판 설정 조회', 'board_settings_bulk_apply' => '게시판 설정 일괄 적용', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php index 10041c1e..99407a90 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/messages.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/messages.php @@ -124,6 +124,19 @@ return [ 'post_deleted' => '삭제된 게시글에는 댓글을 작성할 수 없습니다.', ], + // 반응(추천/비추천) 관련 메시지 + 'reaction' => [ + 'list_success' => '반응 유형 목록을 조회했습니다.', + 'add_success' => '반응을 남겼습니다.', + 'change_success' => '반응을 변경했습니다.', + 'remove_success' => '반응을 취소했습니다.', + 'failed' => '반응 처리에 실패했습니다.', + 'disabled' => '이 게시판은 반응 기능이 비활성화되어 있습니다.', + 'inactive_type' => '이 게시판에서 사용할 수 없는 반응 유형입니다.', + 'self_post' => '본인 글에는 반응할 수 없습니다.', + 'login_required' => '로그인이 필요한 기능입니다.', + ], + // 댓글 관련 추가 메시지 'comments' => [ 'comments_disabled' => '이 게시판은 댓글 기능이 비활성화되어 있습니다.', diff --git a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php index a4f87097..529f0219 100644 --- a/modules/_bundled/sirsoft-board/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-board/src/lang/ko/validation.php @@ -307,6 +307,8 @@ return [ 'basic_defaults.comment_order' => '댓글 정렬', 'basic_defaults.show_view_count' => '조회수 표시', 'basic_defaults.use_report' => '신고 기능 사용', + 'basic_defaults.use_reaction' => '반응 기능 사용', + 'basic_defaults.active_reaction_types' => '사용할 반응 유형', 'basic_defaults.min_title_length' => '최소 제목 길이', 'basic_defaults.max_title_length' => '최대 제목 길이', 'basic_defaults.min_content_length' => '최소 내용 길이', @@ -369,6 +371,9 @@ return [ 'process_note' => '처리 메모', 'ids' => '신고 ID', ], + 'reaction' => [ + 'reaction_type_id' => '반응 유형', + ], 'blind' => [ 'reason' => '블라인드 사유', ], @@ -580,6 +585,12 @@ return [ ], // 신고 검증 메시지 + 'reaction' => [ + 'reaction_type_id' => [ + 'required' => '반응 유형은 필수입니다.', + 'integer' => '반응 유형은 정수여야 합니다.', + ], + ], 'report' => [ 'invalid_status_transition' => '현재 신고 상태에서는 해당 상태로 변경할 수 없습니다.', 'status' => [ diff --git a/modules/_bundled/sirsoft-board/src/routes/api.php b/modules/_bundled/sirsoft-board/src/routes/api.php index 2ca4aee6..2a692def 100644 --- a/modules/_bundled/sirsoft-board/src/routes/api.php +++ b/modules/_bundled/sirsoft-board/src/routes/api.php @@ -8,11 +8,13 @@ use Modules\Sirsoft\Board\Http\Controllers\Admin\BoardTypeController; use Modules\Sirsoft\Board\Http\Controllers\Admin\CommentController as AdminCommentController; use Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController; use Modules\Sirsoft\Board\Http\Controllers\Admin\PostController as AdminPostController; +use Modules\Sirsoft\Board\Http\Controllers\Admin\ReactionTypeController as AdminReactionTypeController; use Modules\Sirsoft\Board\Http\Controllers\Admin\ReportController as AdminReportController; use Modules\Sirsoft\Board\Http\Controllers\User\AttachmentController as UserAttachmentController; use Modules\Sirsoft\Board\Http\Controllers\User\BoardController as UserBoardController; use Modules\Sirsoft\Board\Http\Controllers\User\CommentController as UserCommentController; use Modules\Sirsoft\Board\Http\Controllers\User\PostController as UserPostController; +use Modules\Sirsoft\Board\Http\Controllers\User\ReactionController as UserReactionController; use Modules\Sirsoft\Board\Http\Controllers\User\ReportController as UserReportController; use Modules\Sirsoft\Board\Http\Controllers\User\UserActivityController; @@ -126,6 +128,11 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function () ->middleware('permission:admin,sirsoft-board.settings.read') ->name('admin.settings.show'); + // 반응 유형 목록 (게시판 설정 체크박스 옵션 소스, 확정 02·03) + Route::get('reaction-types', [AdminReactionTypeController::class, 'index']) + ->middleware('permission:admin,sirsoft-board.settings.read') + ->name('admin.reaction-types.index'); + // 대시보드 - 오늘 새 글/댓글 현황 (진입 가드는 코어 core.dashboard.read + admin) Route::get('dashboard/overview', [DashboardController::class, 'overview']) ->name('admin.dashboard.overview'); @@ -540,6 +547,10 @@ Route::prefix('boards/{slug}')->middleware(['throttle:600,1', 'auth:sanctum'])-> // 댓글 신고 Route::post('/comments/{commentId}/reports', [UserReportController::class, 'storeCommentReport']) ->name('comments.reports.store'); + + // 게시글 반응 (추천/비추천) — 등록/전환/해제 통합 (회원 전용, 확정 07) + Route::post('/posts/{postId}/react', [UserReactionController::class, 'react']) + ->name('posts.react'); }); /* diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReactionTypeApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReactionTypeApiTest.php new file mode 100644 index 00000000..cc00693b --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Admin/ReactionTypeApiTest.php @@ -0,0 +1,100 @@ +delete(); + ReactionType::query()->delete(); + $this->seed(BoardReactionTypeSeeder::class); + } + + /** + * 권한 있는 관리자는 활성 유형 목록을 display_order 순으로 조회한다. + */ + public function test_admin_can_list_active_reaction_types(): void + { + $admin = $this->createAdminUser(['sirsoft-board.settings.read']); + + $response = $this->actingAs($admin) + ->withHeader('Accept-Language', 'ko') + ->getJson(self::ENDPOINT); + + $response->assertOk() + ->assertJsonPath('data.reaction_types.0.code', 'like') + ->assertJsonPath('data.reaction_types.0.name', '추천') + ->assertJsonPath('data.reaction_types.0.icon', 'fas fa-thumbs-up') + ->assertJsonPath('data.reaction_types.1.code', 'dislike') + ->assertJsonPath('data.reaction_types.1.name', '비추천'); + } + + /** + * en 로케일에서는 영어 라벨을 반환한다. + */ + public function test_reaction_type_name_localized_to_en(): void + { + $admin = $this->createAdminUser(['sirsoft-board.settings.read']); + + $this->actingAs($admin) + ->withHeader('Accept-Language', 'en') + ->getJson(self::ENDPOINT) + ->assertOk() + ->assertJsonPath('data.reaction_types.0.name', 'Recommend'); + } + + /** + * 미입력 언어는 사이트 기본 언어(fallback)로 대체 노출된다 (확정 15). + */ + public function test_missing_locale_falls_back(): void + { + // ja 미입력 유형을 하나 추가해 폴백 경로를 직접 검증 + ReactionType::create([ + 'code' => 'love', + 'name' => ['ko' => '사랑', 'en' => 'Love'], + 'icon' => 'fas fa-heart', + 'display_order' => 3, + 'is_active' => true, + ]); + + $admin = $this->createAdminUser(['sirsoft-board.settings.read']); + + $response = $this->actingAs($admin) + ->withHeader('Accept-Language', 'ja') + ->getJson(self::ENDPOINT); + $response->assertOk(); + + $love = collect($response->json('data.reaction_types'))->firstWhere('code', 'love'); + $this->assertNotNull($love); + // ja 미입력 → fallback_locale(en) 또는 첫 값으로 폴백 (빈 문자열 아님) + $this->assertNotSame('', $love['name']); + } + + /** + * 권한 없는 사용자는 403 으로 차단된다. + */ + public function test_user_without_permission_forbidden(): void + { + $user = $this->createUser(); + + $this->actingAs($user)->getJson(self::ENDPOINT) + ->assertForbidden(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php new file mode 100644 index 00000000..f699138a --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/ReactionApiTest.php @@ -0,0 +1,190 @@ +delete(); + ReactionType::query()->delete(); + $this->seed(BoardReactionTypeSeeder::class); + + $this->likeId = ReactionType::query()->where('code', 'like')->value('id'); + $this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id'); + + $this->updateBoardSettings([ + 'use_reaction' => true, + 'active_reaction_types' => ['like', 'dislike'], + ]); + } + + /** + * 반응 API 엔드포인트 URL 을 조립합니다. + */ + private function reactUrl(int $postId): string + { + return "/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}/react"; + } + + /** + * 반응 등록 → 카운트 +1, my_reaction_type_id 반영. + */ + public function test_register_reaction(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $response = $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]); + + $response->assertOk() + ->assertJsonPath('data.action', 'add') + ->assertJsonPath('data.my_reaction_type_id', $this->likeId) + ->assertJsonPath("data.reaction_counts.{$this->likeId}", 1); + + $this->assertDatabaseHas('board_reactions', [ + 'user_id' => $reactor->id, + 'target_id' => $postId, + 'reaction_type_id' => $this->likeId, + ]); + } + + /** + * 다른 유형으로 전환 → 이전 -1·신규 +1 (증감 정합성). + */ + public function test_switch_reaction_adjusts_counts(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]) + ->assertOk(); + + $response = $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId]); + + $response->assertOk() + ->assertJsonPath('data.action', 'change') + ->assertJsonPath('data.my_reaction_type_id', $this->dislikeId) + ->assertJsonPath("data.reaction_counts.{$this->likeId}", 0) + ->assertJsonPath("data.reaction_counts.{$this->dislikeId}", 1); + } + + /** + * 같은 유형 재클릭 → 해제, 카운트 -1, my_reaction_type_id null. + */ + public function test_remove_reaction_on_same_type(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]) + ->assertOk(); + + $response = $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]); + + $response->assertOk() + ->assertJsonPath('data.action', 'remove') + ->assertJsonPath('data.my_reaction_type_id', null) + ->assertJsonPath("data.reaction_counts.{$this->likeId}", 0); + + $this->assertDatabaseMissing('board_reactions', [ + 'user_id' => $reactor->id, + 'target_id' => $postId, + ]); + } + + /** + * 비로그인 요청은 401 로 차단된다 (확정 07). + */ + public function test_guest_cannot_react(): void + { + $author = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]) + ->assertUnauthorized(); + } + + /** + * use_reaction 이 꺼진 게시판은 422 로 차단된다. + */ + public function test_react_blocked_when_use_reaction_off(): void + { + $this->updateBoardSettings(['use_reaction' => false]); + + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]) + ->assertStatus(422); + } + + /** + * 게시판이 켜지 않은(비활성) 유형은 검증 단계(422)에서 차단된다. + */ + public function test_react_blocked_for_inactive_type(): void + { + $this->updateBoardSettings(['active_reaction_types' => ['like']]); + + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->dislikeId]) + ->assertStatus(422); + } + + /** + * 본인 글에는 반응할 수 없다 (422, 확정 08). + */ + public function test_cannot_react_to_own_post(): void + { + $author = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->actingAs($author, 'sanctum') + ->postJson($this->reactUrl($postId), ['reaction_type_id' => $this->likeId]) + ->assertStatus(422); + } + + /** + * 다른 게시판 소속이 아닌(존재하지 않는) 게시글은 404 로 차단된다. + */ + public function test_react_to_missing_post_returns_404(): void + { + $reactor = $this->createUser(); + + $this->actingAs($reactor, 'sanctum') + ->postJson($this->reactUrl(999999), ['reaction_type_id' => $this->likeId]) + ->assertNotFound(); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/BoardReactionTypeSeederTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/BoardReactionTypeSeederTest.php new file mode 100644 index 00000000..7dadaa8a --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/BoardReactionTypeSeederTest.php @@ -0,0 +1,87 @@ +delete(); + } + + /** + * 시더가 추천/비추천 2건을 정확한 값으로 등록한다. + */ + public function test_seeder_creates_like_and_dislike_types(): void + { + ReactionType::query()->delete(); + + $this->seed(BoardReactionTypeSeeder::class); + + $this->assertSame(2, ReactionType::query()->count()); + + $like = ReactionType::query()->where('code', 'like')->first(); + $this->assertNotNull($like); + $this->assertSame('fas fa-thumbs-up', $like->icon); + $this->assertSame(1, $like->display_order); + $this->assertTrue($like->is_active); + $this->assertSame( + ['ko' => '추천', 'en' => 'Recommend', 'ja' => 'おすすめ'], + $like->name + ); + + $dislike = ReactionType::query()->where('code', 'dislike')->first(); + $this->assertNotNull($dislike); + $this->assertSame('fas fa-thumbs-down', $dislike->icon); + $this->assertSame(2, $dislike->display_order); + $this->assertTrue($dislike->is_active); + $this->assertSame( + ['ko' => '비추천', 'en' => 'Not Recommend', 'ja' => 'ひどい'], + $dislike->name + ); + } + + /** + * 시더는 code로 매칭하는 upsert라 재실행해도 중복 생성되지 않는다. + */ + public function test_seeder_is_idempotent(): void + { + ReactionType::query()->delete(); + + $this->seed(BoardReactionTypeSeeder::class); + $this->seed(BoardReactionTypeSeeder::class); + + $this->assertSame(2, ReactionType::query()->count()); + } + + /** + * 한글이 유니코드 이스케이프 없이 저장된다 (AsUnicodeJson 캐스트). + */ + public function test_name_stored_as_unicode_json(): void + { + ReactionType::query()->delete(); + + $this->seed(BoardReactionTypeSeeder::class); + + $raw = \Illuminate\Support\Facades\DB::table('board_reaction_types') + ->where('code', 'like') + ->value('name'); + + $this->assertStringContainsString('추천', $raw); + $this->assertStringNotContainsString('\\u', $raw); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/ReactionRepositoryTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/ReactionRepositoryTest.php new file mode 100644 index 00000000..7ec3b136 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/ReactionRepositoryTest.php @@ -0,0 +1,147 @@ +delete(); + ReactionType::query()->delete(); + $this->seed(BoardReactionTypeSeeder::class); + + $this->likeId = ReactionType::query()->where('code', 'like')->value('id'); + $this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id'); + + $this->reactionRepository = app(ReactionRepositoryInterface::class); + $this->reactionTypeRepository = app(ReactionTypeRepositoryInterface::class); + } + + /** + * upsert 는 없으면 INSERT, 있으면 유형만 UPDATE 한다 (사용자당 1행 유지). + */ + public function test_upsert_inserts_then_updates_single_row(): void + { + $user = $this->createUser(); + $postId = $this->createTestPost(); + + $first = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id); + $this->assertSame($this->likeId, (int) $first->reaction_type_id); + + $second = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->dislikeId, $this->board->id); + $this->assertSame($first->id, $second->id); + $this->assertSame($this->dislikeId, (int) $second->reaction_type_id); + $this->assertSame(1, \Modules\Sirsoft\Board\Models\Reaction::query() + ->where('user_id', $user->id)->where('target_id', $postId)->count()); + } + + /** + * findByUserAndTarget 은 사용자+대상 유일 키로 조회한다. + */ + public function test_find_by_user_and_target(): void + { + $user = $this->createUser(); + $postId = $this->createTestPost(); + $this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id); + + $found = $this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId); + $this->assertNotNull($found); + $this->assertSame($this->likeId, (int) $found->reaction_type_id); + + $this->assertNull($this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId + 999)); + } + + /** + * delete 는 이력 행을 삭제한다. + */ + public function test_delete_removes_reaction(): void + { + $user = $this->createUser(); + $postId = $this->createTestPost(); + $reaction = $this->reactionRepository->upsert($user->id, 'post', $postId, $this->likeId, $this->board->id); + + $this->assertTrue($this->reactionRepository->delete($reaction)); + $this->assertNull($this->reactionRepository->findByUserAndTarget($user->id, 'post', $postId)); + } + + /** + * adjustPostReactionCounts 는 유형별 증감을 적용하고 0 미만으로 내려가지 않는다. + */ + public function test_adjust_post_reaction_counts_applies_deltas_and_clamps(): void + { + $postId = $this->createTestPost(['reaction_counts' => json_encode([(string) $this->likeId => 2])]); + + $counts = $this->reactionRepository->adjustPostReactionCounts($postId, [ + $this->likeId => -1, + $this->dislikeId => 1, + ]); + + $this->assertSame(1, $counts[(string) $this->likeId]); + $this->assertSame(1, $counts[(string) $this->dislikeId]); + + // 클램프: 0 아래로 내려가지 않음 + $clamped = $this->reactionRepository->adjustPostReactionCounts($postId, [$this->likeId => -5]); + $this->assertSame(0, $clamped[(string) $this->likeId]); + + $post = Post::findOrFail($postId); + $this->assertSame(0, $post->reaction_counts[(string) $this->likeId]); + } + + /** + * getActive 는 활성 유형을 display_order 순으로 반환한다. + */ + public function test_get_active_returns_ordered_active_types(): void + { + $active = $this->reactionTypeRepository->getActive(); + + $this->assertSame(['like', 'dislike'], $active->pluck('code')->all()); + } + + /** + * findByCodes 는 code 목록으로 활성 유형만 조회한다. + */ + public function test_find_by_codes(): void + { + $found = $this->reactionTypeRepository->findByCodes(['dislike']); + $this->assertSame(['dislike'], $found->pluck('code')->all()); + + $this->assertTrue($this->reactionTypeRepository->findByCodes([])->isEmpty()); + } + + /** + * findById / findByIds 로 유형을 조회한다. + */ + public function test_find_by_id_and_ids(): void + { + $this->assertSame('like', $this->reactionTypeRepository->findById($this->likeId)?->code); + $this->assertNull($this->reactionTypeRepository->findById(999999)); + + $byIds = $this->reactionTypeRepository->findByIds([$this->likeId, $this->dislikeId]); + $this->assertSame(['like', 'dislike'], $byIds->pluck('code')->all()); + $this->assertTrue($this->reactionTypeRepository->findByIds([])->isEmpty()); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php new file mode 100644 index 00000000..27247f71 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Services/ReactionServiceTest.php @@ -0,0 +1,184 @@ +delete(); + ReactionType::query()->delete(); + $this->seed(BoardReactionTypeSeeder::class); + + $this->likeId = ReactionType::query()->where('code', 'like')->value('id'); + $this->dislikeId = ReactionType::query()->where('code', 'dislike')->value('id'); + + $this->updateBoardSettings([ + 'use_reaction' => true, + 'active_reaction_types' => ['like', 'dislike'], + ]); + + $this->service = app(ReactionService::class); + } + + /** + * 반응이 없던 게시글에 반응하면 신규 등록되고 카운트가 +1 된다. + */ + public function test_react_adds_new_reaction_and_increments_count(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $result = $this->service->react($reactor->id, $this->board, $postId, $this->likeId); + + $this->assertSame('add', $result['action']); + $this->assertSame($this->likeId, $result['reaction_type_id']); + $this->assertSame(1, $result['reaction_counts'][(string) $this->likeId]); + + $this->assertDatabaseHas('board_reactions', [ + 'user_id' => $reactor->id, + 'target_type' => 'post', + 'target_id' => $postId, + 'reaction_type_id' => $this->likeId, + ]); + } + + /** + * 다른 유형으로 전환하면 이전 유형 -1·신규 유형 +1 이 동시 반영된다 (단일 트랜잭션). + */ + public function test_react_switches_type_and_adjusts_both_counts(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->service->react($reactor->id, $this->board, $postId, $this->likeId); + $result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->dislikeId); + + $this->assertSame('change', $result['action']); + $this->assertSame(0, $result['reaction_counts'][(string) $this->likeId]); + $this->assertSame(1, $result['reaction_counts'][(string) $this->dislikeId]); + + // 사용자당 1행 유지 (전환은 UPDATE) + $this->assertSame(1, Reaction::query()->where('user_id', $reactor->id)->where('target_id', $postId)->count()); + $this->assertDatabaseHas('board_reactions', [ + 'user_id' => $reactor->id, + 'target_id' => $postId, + 'reaction_type_id' => $this->dislikeId, + ]); + } + + /** + * 같은 유형을 재클릭하면 해제되어 이력 행이 삭제되고 카운트가 -1 된다. + */ + public function test_react_same_type_removes_reaction_and_decrements_count(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->service->react($reactor->id, $this->board, $postId, $this->likeId); + $result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId); + + $this->assertSame('remove', $result['action']); + $this->assertNull($result['reaction_type_id']); + $this->assertSame(0, $result['reaction_counts'][(string) $this->likeId]); + + $this->assertDatabaseMissing('board_reactions', [ + 'user_id' => $reactor->id, + 'target_id' => $postId, + ]); + } + + /** + * 카운트는 0 미만으로 내려가지 않는다 (해제 시 클램프). + */ + public function test_reaction_count_never_goes_below_zero(): void + { + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id, 'reaction_counts' => json_encode([])]); + + $this->service->react($reactor->id, $this->board, $postId, $this->likeId); + $result = $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId); + + $this->assertGreaterThanOrEqual(0, $result['reaction_counts'][(string) $this->likeId]); + } + + /** + * use_reaction 이 꺼진 게시판은 반응이 차단된다. + */ + public function test_react_blocked_when_use_reaction_off(): void + { + $this->updateBoardSettings(['use_reaction' => false]); + + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->expectException(ReactionNotAllowedException::class); + $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->likeId); + } + + /** + * 게시판이 켜지 않은(비활성) 유형은 차단된다. + */ + public function test_react_blocked_for_inactive_type_on_board(): void + { + $this->updateBoardSettings(['active_reaction_types' => ['like']]); + + $author = $this->createUser(); + $reactor = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->expectException(ReactionNotAllowedException::class); + $this->service->react($reactor->id, $this->board->fresh(), $postId, $this->dislikeId); + } + + /** + * 본인 글에는 반응할 수 없다. + */ + public function test_react_blocked_on_own_post(): void + { + $author = $this->createUser(); + $postId = $this->createTestPost(['user_id' => $author->id]); + + $this->expectException(ReactionNotAllowedException::class); + $this->service->react($author->id, $this->board, $postId, $this->likeId); + } + + /** + * 다른 게시판 소속 게시글 ID 로는 반응할 수 없다 (스코프 검증). + */ + public function test_react_blocked_for_post_not_in_board(): void + { + $reactor = $this->createUser(); + + $this->expectException(PostNotFoundException::class); + $this->service->react($reactor->id, $this->board, 999999, $this->likeId); + } +}