From 6477cf2c83d30c5e38167b1c71e7f3d83ddbcb93 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 18 Dec 2025 11:16:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?API=20=EB=B0=8F=20FCM=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard API: - DashboardController, DashboardService 추가 - /dashboard/summary, /charts, /approvals 엔드포인트 Push Notification API: - FCM 토큰 관리 (등록/해제/목록) - 알림 설정 관리 (유형별 on/off, 알림음 설정) - 알림 유형: deposit, withdrawal, order, approval, attendance, notice, system - 알림음: default, deposit, withdrawal, order, approval, urgent - PushDeviceToken, PushNotificationSetting 모델 - Swagger 문서 추가 --- .../Api/V1/DashboardController.php | 47 +++ .../Api/V1/PushNotificationController.php | 91 +++++ .../Requests/Push/RegisterTokenRequest.php | 43 +++ .../Requests/Push/UpdateSettingsRequest.php | 45 +++ .../Dashboard/DashboardApprovalsRequest.php | 28 ++ .../V1/Dashboard/DashboardChartsRequest.php | 27 ++ app/Models/PushDeviceToken.php | 79 ++++ app/Models/PushNotificationSetting.php | 131 +++++++ app/Services/DashboardService.php | 354 ++++++++++++++++++ app/Services/PushNotificationService.php | 277 ++++++++++++++ app/Swagger/v1/DashboardApi.php | 184 +++++++++ app/Swagger/v1/PushApi.php | 303 +++++++++++++++ ...100001_create_push_device_tokens_table.php | 35 ++ ...reate_push_notification_settings_table.php | 34 ++ routes/api.php | 19 + 15 files changed, 1697 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/DashboardController.php create mode 100644 app/Http/Controllers/Api/V1/PushNotificationController.php create mode 100644 app/Http/Requests/Push/RegisterTokenRequest.php create mode 100644 app/Http/Requests/Push/UpdateSettingsRequest.php create mode 100644 app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php create mode 100644 app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php create mode 100644 app/Models/PushDeviceToken.php create mode 100644 app/Models/PushNotificationSetting.php create mode 100644 app/Services/DashboardService.php create mode 100644 app/Services/PushNotificationService.php create mode 100644 app/Swagger/v1/DashboardApi.php create mode 100644 app/Swagger/v1/PushApi.php create mode 100644 database/migrations/2025_12_18_100001_create_push_device_tokens_table.php create mode 100644 database/migrations/2025_12_18_100002_create_push_notification_settings_table.php diff --git a/app/Http/Controllers/Api/V1/DashboardController.php b/app/Http/Controllers/Api/V1/DashboardController.php new file mode 100644 index 0000000..0a6d631 --- /dev/null +++ b/app/Http/Controllers/Api/V1/DashboardController.php @@ -0,0 +1,47 @@ +dashboardService->summary(); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 대시보드 차트 데이터 조회 + */ + public function charts(DashboardChartsRequest $request): JsonResponse + { + $data = $this->dashboardService->charts($request->validated()); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } + + /** + * 결재 현황 조회 + */ + public function approvals(DashboardApprovalsRequest $request): JsonResponse + { + $data = $this->dashboardService->approvals($request->validated()); + + return ApiResponse::handle(['data' => $data], __('message.fetched')); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/V1/PushNotificationController.php b/app/Http/Controllers/Api/V1/PushNotificationController.php new file mode 100644 index 0000000..a3e911c --- /dev/null +++ b/app/Http/Controllers/Api/V1/PushNotificationController.php @@ -0,0 +1,91 @@ +registerToken($request->validated()); + }, __('message.push.token_registered')); + } + + /** + * FCM 토큰 해제 + */ + public function unregisterToken(Request $request) + { + return ApiResponse::handle(function () use ($request) { + $token = $request->input('token'); + if (! $token) { + throw new \InvalidArgumentException(__('error.push.token_required')); + } + + $service = new PushNotificationService; + + return ['unregistered' => $service->unregisterToken($token)]; + }, __('message.push.token_unregistered')); + } + + /** + * 사용자의 등록된 디바이스 토큰 목록 + */ + public function getTokens() + { + return ApiResponse::handle(function () { + $service = new PushNotificationService; + + return $service->getUserTokens(); + }); + } + + /** + * 알림 설정 조회 + */ + public function getSettings() + { + return ApiResponse::handle(function () { + $service = new PushNotificationService; + + return $service->getSettings(); + }); + } + + /** + * 알림 설정 업데이트 + */ + public function updateSettings(UpdateSettingsRequest $request) + { + return ApiResponse::handle(function () use ($request) { + $service = new PushNotificationService; + + return $service->updateSettings($request->validated()['settings']); + }, __('message.push.settings_updated')); + } + + /** + * 알림 유형 목록 조회 + */ + public function getNotificationTypes() + { + return ApiResponse::handle(function () { + return [ + 'types' => \App\Models\PushNotificationSetting::getAllTypes(), + 'sounds' => \App\Models\PushNotificationSetting::getAllSounds(), + ]; + }); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Push/RegisterTokenRequest.php b/app/Http/Requests/Push/RegisterTokenRequest.php new file mode 100644 index 0000000..5f71378 --- /dev/null +++ b/app/Http/Requests/Push/RegisterTokenRequest.php @@ -0,0 +1,43 @@ + ['required', 'string', 'min:10'], + 'platform' => [ + 'required', + 'string', + Rule::in([ + PushDeviceToken::PLATFORM_IOS, + PushDeviceToken::PLATFORM_ANDROID, + PushDeviceToken::PLATFORM_WEB, + ]), + ], + 'device_name' => ['nullable', 'string', 'max:255'], + 'app_version' => ['nullable', 'string', 'max:50'], + ]; + } + + public function messages(): array + { + return [ + 'token.required' => __('error.push.token_required'), + 'token.min' => __('error.push.token_invalid'), + 'platform.required' => __('error.push.platform_required'), + 'platform.in' => __('error.push.platform_invalid'), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Push/UpdateSettingsRequest.php b/app/Http/Requests/Push/UpdateSettingsRequest.php new file mode 100644 index 0000000..91060ba --- /dev/null +++ b/app/Http/Requests/Push/UpdateSettingsRequest.php @@ -0,0 +1,45 @@ + ['required', 'array'], + 'settings.*.notification_type' => [ + 'required', + 'string', + Rule::in(PushNotificationSetting::getAllTypes()), + ], + 'settings.*.is_enabled' => ['required', 'boolean'], + 'settings.*.sound' => [ + 'nullable', + 'string', + Rule::in(PushNotificationSetting::getAllSounds()), + ], + 'settings.*.vibrate' => ['nullable', 'boolean'], + 'settings.*.show_preview' => ['nullable', 'boolean'], + ]; + } + + public function messages(): array + { + return [ + 'settings.required' => __('error.push.settings_required'), + 'settings.*.notification_type.required' => __('error.push.type_required'), + 'settings.*.notification_type.in' => __('error.push.type_invalid'), + 'settings.*.is_enabled.required' => __('error.push.enabled_required'), + ]; + } +} diff --git a/app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php b/app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php new file mode 100644 index 0000000..147700a --- /dev/null +++ b/app/Http/Requests/V1/Dashboard/DashboardApprovalsRequest.php @@ -0,0 +1,28 @@ + ['nullable', 'integer', 'min:1', 'max:50'], + ]; + } + + public function messages(): array + { + return [ + 'limit.min' => __('error.validation.min', ['min' => 1]), + 'limit.max' => __('error.validation.max', ['max' => 50]), + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php b/app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php new file mode 100644 index 0000000..dca0959 --- /dev/null +++ b/app/Http/Requests/V1/Dashboard/DashboardChartsRequest.php @@ -0,0 +1,27 @@ + ['nullable', 'string', 'in:week,month,quarter'], + ]; + } + + public function messages(): array + { + return [ + 'period.in' => __('error.dashboard.invalid_period'), + ]; + } +} \ No newline at end of file diff --git a/app/Models/PushDeviceToken.php b/app/Models/PushDeviceToken.php new file mode 100644 index 0000000..764bb5d --- /dev/null +++ b/app/Models/PushDeviceToken.php @@ -0,0 +1,79 @@ + 'boolean', + 'last_used_at' => 'datetime', + ]; + + /** + * 플랫폼 상수 + */ + public const PLATFORM_IOS = 'ios'; + + public const PLATFORM_ANDROID = 'android'; + + public const PLATFORM_WEB = 'web'; + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * Scope: 활성 토큰만 + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: 플랫폼별 필터 + */ + public function scopePlatform($query, string $platform) + { + return $query->where('platform', $platform); + } + + /** + * Scope: 특정 사용자의 토큰 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } +} \ No newline at end of file diff --git a/app/Models/PushNotificationSetting.php b/app/Models/PushNotificationSetting.php new file mode 100644 index 0000000..7dbdd21 --- /dev/null +++ b/app/Models/PushNotificationSetting.php @@ -0,0 +1,131 @@ + 'boolean', + 'vibrate' => 'boolean', + 'show_preview' => 'boolean', + ]; + + /** + * 알림 유형 상수 + */ + public const TYPE_DEPOSIT = 'deposit'; // 입금 + + public const TYPE_WITHDRAWAL = 'withdrawal'; // 출금 + + public const TYPE_ORDER = 'order'; // 수주 + + public const TYPE_APPROVAL = 'approval'; // 결재 + + public const TYPE_ATTENDANCE = 'attendance'; // 근태 + + public const TYPE_NOTICE = 'notice'; // 공지사항 + + public const TYPE_SYSTEM = 'system'; // 시스템 + + /** + * 알림음 상수 + */ + public const SOUND_DEFAULT = 'default'; + + public const SOUND_DEPOSIT = 'deposit.wav'; // 입금 알림음 + + public const SOUND_WITHDRAWAL = 'withdrawal.wav'; // 출금 알림음 + + public const SOUND_ORDER = 'order.wav'; // 수주 알림음 + + public const SOUND_APPROVAL = 'approval.wav'; // 결재 알림음 + + public const SOUND_URGENT = 'urgent.wav'; // 긴급 알림음 + + /** + * 모든 알림 유형 반환 + */ + public static function getAllTypes(): array + { + return [ + self::TYPE_DEPOSIT, + self::TYPE_WITHDRAWAL, + self::TYPE_ORDER, + self::TYPE_APPROVAL, + self::TYPE_ATTENDANCE, + self::TYPE_NOTICE, + self::TYPE_SYSTEM, + ]; + } + + /** + * 모든 알림음 반환 + */ + public static function getAllSounds(): array + { + return [ + self::SOUND_DEFAULT, + self::SOUND_DEPOSIT, + self::SOUND_WITHDRAWAL, + self::SOUND_ORDER, + self::SOUND_APPROVAL, + self::SOUND_URGENT, + ]; + } + + /** + * 사용자 관계 + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 테넌트 관계 + */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + /** + * Scope: 활성화된 알림만 + */ + public function scopeEnabled($query) + { + return $query->where('is_enabled', true); + } + + /** + * Scope: 특정 사용자의 설정 + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope: 특정 알림 유형 + */ + public function scopeOfType($query, string $type) + { + return $query->where('notification_type', $type); + } +} \ No newline at end of file diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php new file mode 100644 index 0000000..607f746 --- /dev/null +++ b/app/Services/DashboardService.php @@ -0,0 +1,354 @@ +tenantId(); + $userId = $this->apiUserId(); + $today = Carbon::today(); + $startOfMonth = Carbon::now()->startOfMonth(); + $endOfMonth = Carbon::now()->endOfMonth(); + + return [ + 'today' => $this->getTodaySummary($tenantId, $today), + 'finance' => $this->getFinanceSummary($tenantId, $startOfMonth, $endOfMonth), + 'sales' => $this->getSalesSummary($tenantId, $startOfMonth, $endOfMonth), + 'tasks' => $this->getTasksSummary($tenantId, $userId), + ]; + } + + /** + * 대시보드 차트 데이터 조회 + * + * @param array $params [period: week|month|quarter] + */ + public function charts(array $params): array + { + $tenantId = $this->tenantId(); + $period = $params['period'] ?? 'month'; + + [$startDate, $endDate] = $this->getPeriodRange($period); + + return [ + 'period' => $period, + 'start_date' => $startDate->toDateString(), + 'end_date' => $endDate->toDateString(), + 'deposit_trend' => $this->getDepositTrend($tenantId, $startDate, $endDate), + 'withdrawal_trend' => $this->getWithdrawalTrend($tenantId, $startDate, $endDate), + 'sales_by_client' => $this->getSalesByClient($tenantId, $startDate, $endDate), + ]; + } + + /** + * 결재 현황 조회 + * + * @param array $params [limit: int] + */ + public function approvals(array $params): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + $limit = $params['limit'] ?? 10; + + // 내가 결재할 문서 (결재함) + $pendingApprovals = $this->getPendingApprovals($tenantId, $userId, $limit); + + // 내가 기안한 문서 중 진행중인 것 + $myDrafts = $this->getMyPendingDrafts($tenantId, $userId, $limit); + + return [ + 'pending_approvals' => $pendingApprovals, + 'my_drafts' => $myDrafts, + ]; + } + + /** + * 오늘 요약 데이터 + */ + private function getTodaySummary(int $tenantId, Carbon $today): array + { + // 오늘 출근자 수 + $attendancesCount = Attendance::query() + ->where('tenant_id', $tenantId) + ->whereDate('work_date', $today) + ->whereNotNull('check_in') + ->count(); + + // 오늘 휴가자 수 + $leavesCount = Leave::query() + ->where('tenant_id', $tenantId) + ->where('status', 'approved') + ->whereDate('start_date', '<=', $today) + ->whereDate('end_date', '>=', $today) + ->count(); + + // 결재 대기 문서 수 (전체) + $approvalsPending = Approval::query() + ->where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + return [ + 'date' => $today->toDateString(), + 'attendances_count' => $attendancesCount, + 'leaves_count' => $leavesCount, + 'approvals_pending' => $approvalsPending, + ]; + } + + /** + * 재무 요약 데이터 + */ + private function getFinanceSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array + { + // 월간 입금 합계 + $monthlyDeposit = Deposit::query() + ->where('tenant_id', $tenantId) + ->whereBetween('deposit_date', [$startOfMonth, $endOfMonth]) + ->sum('amount'); + + // 월간 출금 합계 + $monthlyWithdrawal = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereBetween('withdrawal_date', [$startOfMonth, $endOfMonth]) + ->sum('amount'); + + // 현재 잔액 (전체 입금 - 전체 출금) + $totalDeposits = Deposit::query() + ->where('tenant_id', $tenantId) + ->sum('amount'); + + $totalWithdrawals = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->sum('amount'); + + $balance = $totalDeposits - $totalWithdrawals; + + return [ + 'monthly_deposit' => (float) $monthlyDeposit, + 'monthly_withdrawal' => (float) $monthlyWithdrawal, + 'balance' => (float) $balance, + ]; + } + + /** + * 매출/매입 요약 데이터 + */ + private function getSalesSummary(int $tenantId, Carbon $startOfMonth, Carbon $endOfMonth): array + { + // 월간 매출 합계 + $monthlySales = Sale::query() + ->where('tenant_id', $tenantId) + ->whereBetween('sale_date', [$startOfMonth, $endOfMonth]) + ->sum('total_amount'); + + // 월간 매입 합계 + $monthlyPurchases = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) + ->sum('total_amount'); + + return [ + 'monthly_sales' => (float) $monthlySales, + 'monthly_purchases' => (float) $monthlyPurchases, + ]; + } + + /** + * 할 일 요약 데이터 + */ + private function getTasksSummary(int $tenantId, int $userId): array + { + // 내가 결재해야 할 문서 수 + $pendingApprovals = ApprovalStep::query() + ->whereHas('approval', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId) + ->where('status', 'pending'); + }) + ->where('approver_id', $userId) + ->where('status', 'pending') + ->count(); + + // 승인 대기 휴가 신청 수 (관리자용) + $pendingLeaves = Leave::query() + ->where('tenant_id', $tenantId) + ->where('status', 'pending') + ->count(); + + return [ + 'pending_approvals' => $pendingApprovals, + 'pending_leaves' => $pendingLeaves, + ]; + } + + /** + * 기간 범위 계산 + * + * @return array [Carbon $startDate, Carbon $endDate] + */ + private function getPeriodRange(string $period): array + { + $endDate = Carbon::today(); + + switch ($period) { + case 'week': + $startDate = $endDate->copy()->subDays(6); + break; + case 'quarter': + $startDate = $endDate->copy()->subMonths(3)->startOfMonth(); + break; + case 'month': + default: + $startDate = $endDate->copy()->subDays(29); + break; + } + + return [$startDate, $endDate]; + } + + /** + * 입금 추이 데이터 + */ + private function getDepositTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array + { + $deposits = Deposit::query() + ->where('tenant_id', $tenantId) + ->whereBetween('deposit_date', [$startDate, $endDate]) + ->select( + DB::raw('DATE(deposit_date) as date'), + DB::raw('SUM(amount) as amount') + ) + ->groupBy(DB::raw('DATE(deposit_date)')) + ->orderBy('date') + ->get(); + + return $deposits->map(function ($item) { + return [ + 'date' => $item->date, + 'amount' => (float) $item->amount, + ]; + })->toArray(); + } + + /** + * 출금 추이 데이터 + */ + private function getWithdrawalTrend(int $tenantId, Carbon $startDate, Carbon $endDate): array + { + $withdrawals = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereBetween('withdrawal_date', [$startDate, $endDate]) + ->select( + DB::raw('DATE(withdrawal_date) as date'), + DB::raw('SUM(amount) as amount') + ) + ->groupBy(DB::raw('DATE(withdrawal_date)')) + ->orderBy('date') + ->get(); + + return $withdrawals->map(function ($item) { + return [ + 'date' => $item->date, + 'amount' => (float) $item->amount, + ]; + })->toArray(); + } + + /** + * 거래처별 매출 데이터 + */ + private function getSalesByClient(int $tenantId, Carbon $startDate, Carbon $endDate): array + { + $sales = Sale::query() + ->where('tenant_id', $tenantId) + ->whereBetween('sale_date', [$startDate, $endDate]) + ->with('client:id,name') + ->select( + 'client_id', + DB::raw('SUM(total_amount) as amount') + ) + ->groupBy('client_id') + ->orderByDesc('amount') + ->limit(10) + ->get(); + + return $sales->map(function ($item) { + return [ + 'client_id' => $item->client_id, + 'client_name' => $item->client?->name ?? __('message.dashboard.unknown_client'), + 'amount' => (float) $item->amount, + ]; + })->toArray(); + } + + /** + * 내가 결재해야 할 문서 목록 + */ + private function getPendingApprovals(int $tenantId, int $userId, int $limit): array + { + $steps = ApprovalStep::query() + ->whereHas('approval', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId) + ->where('status', 'pending'); + }) + ->where('approver_id', $userId) + ->where('status', 'pending') + ->with(['approval' => function ($query) { + $query->with('drafter:id,name'); + }]) + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return $steps->map(function ($step) { + return [ + 'id' => $step->approval->id, + 'title' => $step->approval->title, + 'drafter_name' => $step->approval->drafter?->name ?? '', + 'status' => $step->approval->status, + 'created_at' => $step->approval->created_at?->toDateTimeString(), + ]; + })->toArray(); + } + + /** + * 내가 기안한 진행중인 문서 목록 + */ + private function getMyPendingDrafts(int $tenantId, int $userId, int $limit): array + { + $approvals = Approval::query() + ->where('tenant_id', $tenantId) + ->where('drafter_id', $userId) + ->where('status', 'pending') + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return $approvals->map(function ($approval) { + return [ + 'id' => $approval->id, + 'title' => $approval->title, + 'status' => $approval->status, + 'current_step' => $approval->current_step, + 'created_at' => $approval->created_at?->toDateTimeString(), + ]; + })->toArray(); + } +} \ No newline at end of file diff --git a/app/Services/PushNotificationService.php b/app/Services/PushNotificationService.php new file mode 100644 index 0000000..5c9ae78 --- /dev/null +++ b/app/Services/PushNotificationService.php @@ -0,0 +1,277 @@ +tenantId(); + $userId = $this->apiUserId(); + + // 동일 토큰이 있으면 업데이트, 없으면 생성 + $token = PushDeviceToken::withoutGlobalScopes() + ->where('token', $data['token']) + ->first(); + + if ($token) { + // 기존 토큰 업데이트 (다른 사용자의 토큰이면 이전 것은 비활성화) + if ($token->user_id !== $userId || $token->tenant_id !== $tenantId) { + $token->update([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'platform' => $data['platform'], + 'device_name' => $data['device_name'] ?? null, + 'app_version' => $data['app_version'] ?? null, + 'is_active' => true, + 'last_used_at' => now(), + 'deleted_at' => null, + ]); + } else { + $token->update([ + 'platform' => $data['platform'], + 'device_name' => $data['device_name'] ?? null, + 'app_version' => $data['app_version'] ?? null, + 'is_active' => true, + 'last_used_at' => now(), + ]); + } + } else { + // 새 토큰 생성 + $token = PushDeviceToken::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'token' => $data['token'], + 'platform' => $data['platform'], + 'device_name' => $data['device_name'] ?? null, + 'app_version' => $data['app_version'] ?? null, + 'is_active' => true, + 'last_used_at' => now(), + ]); + } + + // 사용자 기본 알림 설정 초기화 (없는 경우) + $this->initializeDefaultSettings($tenantId, $userId); + + Log::info('FCM token registered', [ + 'user_id' => $userId, + 'platform' => $data['platform'], + 'token_id' => $token->id, + ]); + + return $token; + } + + /** + * FCM 토큰 비활성화 + */ + public function unregisterToken(string $tokenValue): bool + { + $token = PushDeviceToken::withoutGlobalScopes() + ->where('token', $tokenValue) + ->first(); + + if ($token) { + $token->update(['is_active' => false]); + + Log::info('FCM token unregistered', [ + 'token_id' => $token->id, + ]); + + return true; + } + + return false; + } + + /** + * 사용자의 활성 토큰 목록 조회 + */ + public function getUserTokens(?int $userId = null): array + { + $userId = $userId ?? $this->apiUserId(); + + return PushDeviceToken::forUser($userId) + ->active() + ->get() + ->toArray(); + } + + /** + * 알림 설정 조회 + */ + public function getSettings(?int $userId = null): array + { + $tenantId = $this->tenantId(); + $userId = $userId ?? $this->apiUserId(); + + $settings = PushNotificationSetting::where('tenant_id', $tenantId) + ->forUser($userId) + ->get() + ->keyBy('notification_type'); + + // 모든 알림 유형에 대한 설정 반환 (없으면 기본값) + $result = []; + foreach (PushNotificationSetting::getAllTypes() as $type) { + if ($settings->has($type)) { + $result[$type] = $settings->get($type)->toArray(); + } else { + $result[$type] = [ + 'notification_type' => $type, + 'is_enabled' => true, + 'sound' => $this->getDefaultSound($type), + 'vibrate' => true, + 'show_preview' => true, + ]; + } + } + + return $result; + } + + /** + * 알림 설정 업데이트 + */ + public function updateSettings(array $settings): array + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $updated = []; + foreach ($settings as $setting) { + $record = PushNotificationSetting::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'notification_type' => $setting['notification_type'], + ], + [ + 'is_enabled' => $setting['is_enabled'], + 'sound' => $setting['sound'] ?? $this->getDefaultSound($setting['notification_type']), + 'vibrate' => $setting['vibrate'] ?? true, + 'show_preview' => $setting['show_preview'] ?? true, + ] + ); + $updated[] = $record->toArray(); + } + + return $updated; + } + + /** + * 특정 사용자에게 푸시 알림 전송 (FCM HTTP v1 API) + */ + public function sendToUser(int $userId, string $notificationType, array $notification): bool + { + $tenantId = $this->tenantIdOrNull() ?? 0; + + // 사용자 알림 설정 확인 + $setting = PushNotificationSetting::where('tenant_id', $tenantId) + ->forUser($userId) + ->ofType($notificationType) + ->first(); + + // 알림이 비활성화된 경우 전송 안함 + if ($setting && ! $setting->is_enabled) { + Log::info('Push notification skipped (disabled)', [ + 'user_id' => $userId, + 'type' => $notificationType, + ]); + + return false; + } + + // 사용자의 활성 토큰 조회 + $tokens = PushDeviceToken::withoutGlobalScopes() + ->forUser($userId) + ->active() + ->get(); + + if ($tokens->isEmpty()) { + Log::info('Push notification skipped (no tokens)', [ + 'user_id' => $userId, + ]); + + return false; + } + + // 알림음 결정 + $sound = $setting?->sound ?? $this->getDefaultSound($notificationType); + + $successCount = 0; + foreach ($tokens as $token) { + $result = $this->sendFcmMessage($token, $notification, $sound, $notificationType); + if ($result) { + $successCount++; + } + } + + return $successCount > 0; + } + + /** + * FCM 메시지 전송 (실제 구현) + */ + protected function sendFcmMessage( + PushDeviceToken $token, + array $notification, + string $sound, + string $notificationType + ): bool { + // TODO: FCM HTTP v1 API 구현 + // 현재는 로그만 기록 + Log::info('FCM message would be sent', [ + 'token_id' => $token->id, + 'platform' => $token->platform, + 'title' => $notification['title'] ?? '', + 'body' => $notification['body'] ?? '', + 'sound' => $sound, + 'type' => $notificationType, + ]); + + return true; + } + + /** + * 기본 알림 설정 초기화 + */ + protected function initializeDefaultSettings(int $tenantId, int $userId): void + { + foreach (PushNotificationSetting::getAllTypes() as $type) { + PushNotificationSetting::firstOrCreate( + [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'notification_type' => $type, + ], + [ + 'is_enabled' => true, + 'sound' => $this->getDefaultSound($type), + 'vibrate' => true, + 'show_preview' => true, + ] + ); + } + } + + /** + * 알림 유형별 기본 알림음 + */ + protected function getDefaultSound(string $type): string + { + return match ($type) { + PushNotificationSetting::TYPE_DEPOSIT => PushNotificationSetting::SOUND_DEPOSIT, + PushNotificationSetting::TYPE_WITHDRAWAL => PushNotificationSetting::SOUND_WITHDRAWAL, + PushNotificationSetting::TYPE_ORDER => PushNotificationSetting::SOUND_ORDER, + PushNotificationSetting::TYPE_APPROVAL => PushNotificationSetting::SOUND_APPROVAL, + default => PushNotificationSetting::SOUND_DEFAULT, + }; + } +} \ No newline at end of file diff --git a/app/Swagger/v1/DashboardApi.php b/app/Swagger/v1/DashboardApi.php new file mode 100644 index 0000000..7816429 --- /dev/null +++ b/app/Swagger/v1/DashboardApi.php @@ -0,0 +1,184 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->text('token')->comment('FCM 디바이스 토큰'); + $table->string('platform', 20)->comment('플랫폼: ios, android, web'); + $table->string('device_name')->nullable()->comment('디바이스명'); + $table->string('app_version', 50)->nullable()->comment('앱 버전'); + $table->boolean('is_active')->default(true)->comment('활성화 여부'); + $table->timestamp('last_used_at')->nullable()->comment('마지막 사용 시간'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['tenant_id', 'user_id']); + $table->index(['user_id', 'is_active']); + $table->index('platform'); + }); + } + + public function down(): void + { + Schema::dropIfExists('push_device_tokens'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_18_100002_create_push_notification_settings_table.php b/database/migrations/2025_12_18_100002_create_push_notification_settings_table.php new file mode 100644 index 0000000..7c9bf26 --- /dev/null +++ b/database/migrations/2025_12_18_100002_create_push_notification_settings_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->unsignedBigInteger('user_id')->comment('사용자 ID'); + $table->string('notification_type', 50)->comment('알림 유형: deposit, withdrawal, order, approval 등'); + $table->boolean('is_enabled')->default(true)->comment('알림 활성화 여부'); + $table->string('sound', 100)->default('default')->comment('알림음 파일명'); + $table->boolean('vibrate')->default(true)->comment('진동 여부'); + $table->boolean('show_preview')->default(true)->comment('미리보기 표시 여부'); + $table->timestamps(); + + // 복합 유니크 키 + $table->unique(['tenant_id', 'user_id', 'notification_type'], 'push_settings_unique'); + + // 인덱스 + $table->index(['tenant_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('push_notification_settings'); + } +}; \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index d5eb447..d4278b1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ClientGroupController; use App\Http\Controllers\Api\V1\CommonController; +use App\Http\Controllers\Api\V1\DashboardController; use App\Http\Controllers\Api\V1\DepartmentController; use App\Http\Controllers\Api\V1\DepositController; use App\Http\Controllers\Api\V1\Design\AuditLogController as DesignAuditLogController; @@ -50,6 +51,7 @@ // use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨 // use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨 use App\Http\Controllers\Api\V1\PricingController; +use App\Http\Controllers\Api\V1\PushNotificationController; use App\Http\Controllers\Api\V1\PurchaseController; use App\Http\Controllers\Api\V1\QuoteController; use App\Http\Controllers\Api\V1\RefreshController; @@ -412,6 +414,13 @@ Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); }); + // Dashboard API (대시보드) + Route::prefix('dashboard')->group(function () { + Route::get('/summary', [DashboardController::class, 'summary'])->name('v1.dashboard.summary'); + Route::get('/charts', [DashboardController::class, 'charts'])->name('v1.dashboard.charts'); + Route::get('/approvals', [DashboardController::class, 'approvals'])->name('v1.dashboard.approvals'); + }); + // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스 @@ -461,6 +470,16 @@ Route::delete('/common/{id}', [CommonController::class, 'destroy'])->name('v1.settings.common.destroy'); // 공통 코드 삭제 }); + // Push Notification API (FCM 푸시 알림) + Route::prefix('push')->group(function () { + Route::post('/register-token', [PushNotificationController::class, 'registerToken'])->name('v1.push.register-token'); // FCM 토큰 등록 + Route::post('/unregister-token', [PushNotificationController::class, 'unregisterToken'])->name('v1.push.unregister-token'); // FCM 토큰 해제 + Route::get('/tokens', [PushNotificationController::class, 'getTokens'])->name('v1.push.tokens'); // 등록된 토큰 목록 + Route::get('/settings', [PushNotificationController::class, 'getSettings'])->name('v1.push.settings'); // 알림 설정 조회 + Route::put('/settings', [PushNotificationController::class, 'updateSettings'])->name('v1.push.settings.update'); // 알림 설정 수정 + Route::get('/notification-types', [PushNotificationController::class, 'getNotificationTypes'])->name('v1.push.notification-types'); // 알림 유형 목록 + }); + // 회원 프로필(테넌트 기준) Route::prefix('profiles')->group(function () { Route::get('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)