diff --git a/app/Http/Controllers/Api/V1/TenantFieldSettingController.php b/app/Http/Controllers/Api/V1/TenantFieldSettingController.php new file mode 100644 index 0000000..83ec8e8 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantFieldSettingController.php @@ -0,0 +1,35 @@ +all()); + }, '테넌트 필드 효과값 목록 조회'); + } + + // PUT /v1/fields/bulk + public function bulkUpsert(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return TenantFieldSettingService::bulkUpsert($request->all()); + }, '테넌트 필드 설정 대량 저장'); + } + + // PATCH /v1/fields/{key} + public function updateOne(Request $request, string $key) + { + return ApiResponse::handle(function () use ($request, $key) { + return TenantFieldSettingService::updateOne($key, $request->all()); + }, '테넌트 필드 설정 단건 수정'); + } +} diff --git a/app/Http/Controllers/Api/V1/TenantOptionGroupController.php b/app/Http/Controllers/Api/V1/TenantOptionGroupController.php new file mode 100644 index 0000000..fbdbdbe --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantOptionGroupController.php @@ -0,0 +1,51 @@ +all()); + }, '옵션 그룹 목록 조회'); + } + + // POST /v1/opt-groups + public function store(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return TenantOptionGroupService::store($request->all()); + }, '옵션 그룹 생성'); + } + + // GET /v1/opt-groups/{id} + public function show($id) + { + return ApiResponse::handle(function () use ($id) { + return TenantOptionGroupService::show($id); + }, '옵션 그룹 단건 조회'); + } + + // PATCH /v1/opt-groups/{id} + public function update(Request $request, $id) + { + return ApiResponse::handle(function () use ($request, $id) { + return TenantOptionGroupService::update($id, $request->all()); + }, '옵션 그룹 수정'); + } + + // DELETE /v1/opt-groups/{id} + public function destroy($id) + { + return ApiResponse::handle(function () use ($id) { + return TenantOptionGroupService::destroy($id); + }, '옵션 그룹 삭제'); + } +} diff --git a/app/Http/Controllers/Api/V1/TenantOptionValueController.php b/app/Http/Controllers/Api/V1/TenantOptionValueController.php new file mode 100644 index 0000000..7f7f338 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantOptionValueController.php @@ -0,0 +1,59 @@ +all()); + }, '옵션 값 목록 조회'); + } + + // POST /v1/opt-groups/{gid}/values + public function store(Request $request, $gid) + { + return ApiResponse::handle(function () use ($request, $gid) { + return TenantOptionValueService::store($gid, $request->all()); + }, '옵션 값 생성'); + } + + // GET /v1/opt-groups/{gid}/values/{id} + public function show($gid, $id) + { + return ApiResponse::handle(function () use ($gid, $id) { + return TenantOptionValueService::show($gid, $id); + }, '옵션 값 단건 조회'); + } + + // PATCH /v1/opt-groups/{gid}/values/{id} + public function update(Request $request, $gid, $id) + { + return ApiResponse::handle(function () use ($request, $gid, $id) { + return TenantOptionValueService::update($gid, $id, $request->all()); + }, '옵션 값 수정'); + } + + // DELETE /v1/opt-groups/{gid}/values/{id} + public function destroy($gid, $id) + { + return ApiResponse::handle(function () use ($gid, $id) { + return TenantOptionValueService::destroy($gid, $id); + }, '옵션 값 삭제'); + } + + // PATCH /v1/opt-groups/{gid}/values/reorder + public function reorder(Request $request, $gid) + { + return ApiResponse::handle(function () use ($request, $gid) { + return TenantOptionValueService::reorder($gid, $request->all()); + }, '옵션 값 정렬 순서 변경'); + } +} diff --git a/app/Http/Controllers/Api/V1/TenantUserProfileController.php b/app/Http/Controllers/Api/V1/TenantUserProfileController.php new file mode 100644 index 0000000..1d68e30 --- /dev/null +++ b/app/Http/Controllers/Api/V1/TenantUserProfileController.php @@ -0,0 +1,51 @@ +all()); + }, '회원 프로필 목록 조회'); + } + + // GET /v1/profiles/{userId} + public function show($userId) + { + return ApiResponse::handle(function () use ($userId) { + return TenantUserProfileService::show($userId); + }, '회원 프로필 단건 조회'); + } + + // PATCH /v1/profiles/{userId} + public function update(Request $request, $userId) + { + return ApiResponse::handle(function () use ($request, $userId) { + return TenantUserProfileService::update($userId, $request->all()); + }, '회원 프로필 수정(관리자)'); + } + + // GET /v1/profiles/me + public function me(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return TenantUserProfileService::me($request->all()); + }, '내 프로필 조회'); + } + + // PATCH /v1/profiles/me + public function updateMe(Request $request) + { + return ApiResponse::handle(function () use ($request) { + return TenantUserProfileService::updateMe($request->all()); + }, '내 프로필 수정'); + } +} diff --git a/app/Models/Tenants/SettingFieldDef.php b/app/Models/Tenants/SettingFieldDef.php new file mode 100644 index 0000000..f4c400d --- /dev/null +++ b/app/Models/Tenants/SettingFieldDef.php @@ -0,0 +1,34 @@ + 'array', + 'is_core' => 'boolean', + ]; + + protected $fillable = [ + 'field_key', + 'label', + 'data_type', + 'input_type', + 'option_source', + 'option_payload', + 'comment', + 'created_at', + 'updated_at', + 'storage_area', + 'storage_key', + 'is_core', + ]; + + // 관계 (테넌트 설정) + public function tenantSettings() + { + return $this->hasMany(TenantFieldSetting::class, 'field_key', 'field_key'); + } +} diff --git a/app/Models/Tenants/TenantFieldSetting.php b/app/Models/Tenants/TenantFieldSetting.php new file mode 100644 index 0000000..be4b981 --- /dev/null +++ b/app/Models/Tenants/TenantFieldSetting.php @@ -0,0 +1,35 @@ + 'boolean', + 'required' => 'boolean', + ]; + + protected $fillable = [ + 'tenant_id', + 'field_key', + 'enabled', + 'required', + 'sort_order', + 'option_group_id', + 'code_group', + ]; + + public function fieldDef() + { + return $this->belongsTo(SettingFieldDef::class, 'field_key', 'field_key'); + } + + public function optionGroup() + { + return $this->belongsTo(TenantOptionGroup::class, 'option_group_id'); + } +} diff --git a/app/Models/Tenants/TenantOptionGroup.php b/app/Models/Tenants/TenantOptionGroup.php new file mode 100644 index 0000000..9791bea --- /dev/null +++ b/app/Models/Tenants/TenantOptionGroup.php @@ -0,0 +1,22 @@ +hasMany(TenantOptionValue::class, 'group_id'); + } +} diff --git a/app/Models/Tenants/TenantOptionValue.php b/app/Models/Tenants/TenantOptionValue.php new file mode 100644 index 0000000..f24bba3 --- /dev/null +++ b/app/Models/Tenants/TenantOptionValue.php @@ -0,0 +1,27 @@ + 'boolean', + ]; + + protected $fillable = [ + 'group_id', + 'value_key', + 'value_label', + 'sort_order', + 'is_active', + ]; + + public function group() + { + return $this->belongsTo(TenantOptionGroup::class, 'group_id'); + } +} diff --git a/app/Models/Tenants/TenantUserProfile.php b/app/Models/Tenants/TenantUserProfile.php new file mode 100644 index 0000000..c41c7cf --- /dev/null +++ b/app/Models/Tenants/TenantUserProfile.php @@ -0,0 +1,42 @@ + 'array', + ]; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'department_id', + 'position_key', + 'job_title_key', + 'work_location_key', + 'employment_type_key', + 'manager_user_id', + 'json_extra', + 'created_at', + 'updated_at', + 'profile_photo_path', + 'display_name', + ]; + + // 관계: users 테이블은 전역이라 App\Models\User 로 연결 + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + // 조직, 직급 등은 옵션/코드 참조 가능 (필요시 추가) + public function department() + { + return $this->belongsTo(Department::class, 'department_id'); + } +} diff --git a/app/Services/TenantFieldSettingService.php b/app/Services/TenantFieldSettingService.php new file mode 100644 index 0000000..1c33166 --- /dev/null +++ b/app/Services/TenantFieldSettingService.php @@ -0,0 +1,213 @@ +enabled : (bool)$def->is_core; + return [ + 'field_key' => $def->field_key, + 'label' => $def->label, + 'data_type' => $def->data_type, + 'input_type' => $def->input_type, + 'option_source' => $def->option_source, + 'option_payload' => $def->option_payload, + 'storage_area' => $def->storage_area, + 'storage_key' => $def->storage_key, + 'is_core' => (bool)$def->is_core, + // tenant overlay + 'enabled' => $enabled, + 'required' => $s ? (bool)$s->required : false, + 'sort_order' => $s ? (int)$s->sort_order : 0, + 'option_group_id' => $s ? $s->option_group_id : null, + 'code_group' => $s ? $s->code_group : null, + ]; + } + + /** + * [GET] 효과값 목록 + * - 코어(is_core=1)는 기본 enabled=true + * - enabled=false는 제외 + * - sort_order → field_key 순 정렬 + */ + public static function index(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $defs = SettingFieldDef::query() + ->orderByDesc('is_core') + ->orderBy('field_key') + ->get() + ->keyBy('field_key'); + + $settings = TenantFieldSetting::where('tenant_id', $tenantId) + ->get() + ->keyBy('field_key'); + + $rows = []; + foreach ($defs as $key => $def) { + $s = $settings->get($key); + $effective = self::buildEffectiveRow($def, $s); + if ($effective['enabled'] !== true) { + continue; + } + $rows[] = $effective; + } + + usort($rows, fn ($a, $b) => + ($a['sort_order'] <=> $b['sort_order']) ?: strcmp($a['field_key'], $b['field_key']) + ); + + return ApiResponse::response('result', array_values($rows)); + } + + /** + * [PUT] 대량 업서트 + * params: + * items: [{field_key, enabled?, required?, sort_order?, option_group_id?, code_group?}, ...] + */ + public static function bulkUpsert(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $v = Validator::make($params, [ + 'items' => ['required','array','min:1'], + 'items.*.field_key' => ['required','string','max:64', Rule::exists('setting_field_defs','field_key')], + 'items.*.enabled' => ['nullable','boolean'], + 'items.*.required' => ['nullable','boolean'], + 'items.*.sort_order' => ['nullable','integer'], + 'items.*.option_group_id' => ['nullable','integer'], + 'items.*.code_group' => ['nullable','string','max:50'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $payload = $v->validated(); + + try { + DB::transaction(function () use ($payload, $tenantId) { + foreach ($payload['items'] as $row) { + // option_group_id 유효성(해당 테넌트 소유인지) + if (!empty($row['option_group_id'])) { + $exists = TenantOptionGroup::where('tenant_id', $tenantId) + ->where('id', $row['option_group_id']) + ->exists(); + if (!$exists) { + throw new \RuntimeException('option_group_id is invalid for this tenant'); + } + } + + TenantFieldSetting::updateOrCreate( + ['tenant_id' => $tenantId, 'field_key' => $row['field_key']], + [ + 'enabled' => isset($row['enabled']) ? (int)$row['enabled'] : 0, + 'required' => isset($row['required']) ? (int)$row['required'] : 0, + 'sort_order' => isset($row['sort_order']) ? (int)$row['sort_order'] : 0, + 'option_group_id' => $row['option_group_id'] ?? null, + 'code_group' => $row['code_group'] ?? null, + ] + ); + } + }); + } catch (\RuntimeException $e) { + return ApiResponse::error($e->getMessage(), 422); + } catch (\Throwable $e) { + return ApiResponse::error('저장 중 오류가 발생했습니다.', 500); + } + + return ApiResponse::response('result', ['updated' => true]); + } + + /** + * [PATCH] 단건 수정 + * - 응답은 FieldEffective 포맷으로 반환 + */ + public static function updateOne(string $fieldKey, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + // 전역 key 존재 확인 + $def = SettingFieldDef::where('field_key', $fieldKey)->first(); + if (!$def) { + return ApiResponse::error('field_key not found', 404); + } + + $v = Validator::make($params, [ + 'enabled' => ['nullable','boolean'], + 'required' => ['nullable','boolean'], + 'sort_order' => ['nullable','integer'], + 'option_group_id' => ['nullable','integer'], + 'code_group' => ['nullable','string','max:50'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + $data = $v->validated(); + + // option_group_id 테넌트 소유 검증 + if (!empty($data['option_group_id'])) { + $ok = TenantOptionGroup::where('tenant_id', $tenantId) + ->where('id', $data['option_group_id']) + ->exists(); + if (!$ok) { + return ApiResponse::error('option_group_id is invalid for this tenant', 422); + } + } + + try { + $item = TenantFieldSetting::firstOrNew([ + 'tenant_id' => $tenantId, + 'field_key' => $fieldKey, + ]); + + // 제공된 키만 반영 + foreach (['enabled','required','sort_order','option_group_id','code_group'] as $k) { + if (array_key_exists($k, $data)) { + $item->{$k} = $data[$k]; + } + } + $item->save(); + + // 최신 설정 다시 로드하여 효과값으로 리턴 + $s = TenantFieldSetting::where('tenant_id', $tenantId) + ->where('field_key', $fieldKey) + ->first(); + + $effective = self::buildEffectiveRow($def, $s); + + return ApiResponse::response('result', $effective); + } catch (\Throwable $e) { + return ApiResponse::error('수정 중 오류가 발생했습니다.', 500); + } + } +} diff --git a/app/Services/TenantOptionGroupService.php b/app/Services/TenantOptionGroupService.php new file mode 100644 index 0000000..dae0f7a --- /dev/null +++ b/app/Services/TenantOptionGroupService.php @@ -0,0 +1,185 @@ +orderBy('group_key'); + + if (!empty($params['q'])) { + $kw = $params['q']; + $q->where(function ($w) use ($kw) { + $w->where('group_key', 'like', "%{$kw}%") + ->orWhere('name', 'like', "%{$kw}%"); + }); + } + + $data = $q->paginate($per, ['*'], 'page', $page); + + return ApiResponse::response('result', $data); + } + + /** + * [POST] 옵션 그룹 생성 + */ + public static function store(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $v = Validator::make($params, [ + 'group_key' => [ + 'required','string','max:64','alpha_dash', + Rule::unique('tenant_option_groups', 'group_key') + ->where(fn ($q) => $q->where('tenant_id', $tenantId)), + ], + 'name' => ['required','string','max:100'], + 'description' => ['nullable','string','max:255'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $data = $v->validated(); + $data['tenant_id'] = $tenantId; + + $item = TenantOptionGroup::create($data); + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [GET] 옵션 그룹 단건 조회 + */ + public static function show(int $id) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + if (!$id) { + return ApiResponse::error('id가 올바르지 않습니다.', 422); + } + + $item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id); + if (!$item) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [PATCH] 옵션 그룹 수정 + */ + public static function update(int $id, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + if (!$id) { + return ApiResponse::error('id가 올바르지 않습니다.', 422); + } + + $item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id); + if (!$item) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'group_key' => [ + 'sometimes','string','max:64','alpha_dash', + Rule::unique('tenant_option_groups', 'group_key') + ->where(fn ($q) => $q->where('tenant_id', $tenantId)) + ->ignore($item->id), + ], + 'name' => ['sometimes','string','max:100'], + 'description' => ['nullable','string','max:255'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $data = $v->validated(); + if (empty($data)) { + return ApiResponse::error('수정할 항목이 없습니다.', 422); + } + + $item->fill($data)->save(); + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [DELETE] 옵션 그룹 삭제 + * - 참조 무결성 검사: + * 1) tenant_option_values에 값이 남아있으면 삭제 불가 + * 2) tenant_field_settings.option_group_id에서 참조 중이면 삭제 불가 + */ + public static function destroy(int $id) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + if (!$id) { + return ApiResponse::error('id가 올바르지 않습니다.', 422); + } + + $item = TenantOptionGroup::where('tenant_id', $tenantId)->find($id); + if (!$item) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + // 1) 옵션 값 존재 여부 + $hasValues = TenantOptionValue::where('group_id', $item->id)->exists(); + if ($hasValues) { + return ApiResponse::error('해당 그룹에 옵션 값이 존재하여 삭제할 수 없습니다.', 409); + } + + // 2) 필드 설정에서 참조 여부 + $isReferenced = TenantFieldSetting::where('tenant_id', $tenantId) + ->where('option_group_id', $item->id) + ->exists(); + if ($isReferenced) { + return ApiResponse::error('필드 설정에서 참조 중인 그룹은 삭제할 수 없습니다.', 409); + } + + $item->delete(); + + return ApiResponse::response('result', ['deleted' => true]); + } +} diff --git a/app/Services/TenantOptionValueService.php b/app/Services/TenantOptionValueService.php new file mode 100644 index 0000000..0581318 --- /dev/null +++ b/app/Services/TenantOptionValueService.php @@ -0,0 +1,231 @@ +find($groupId); + } + + /** + * [GET] 옵션 값 목록 + * params: active_only?(bool) + */ + public static function index(int $groupId, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $q = TenantOptionValue::where('group_id', $group->id) + ->orderBy('sort_order') + ->orderBy('value_label'); + + if (!empty($params['active_only'])) { + $q->where('is_active', 1); + } + + $list = $q->get(); + + return ApiResponse::response('result', $list); + } + + /** + * [POST] 옵션 값 생성 + */ + public static function store(int $groupId, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'value_key' => [ + 'required','string','max:64','alpha_dash', + Rule::unique('tenant_option_values', 'value_key') + ->where(fn ($q) => $q->where('group_id', $group->id)), + ], + 'value_label' => ['required','string','max:100'], + 'sort_order' => ['nullable','integer'], + 'is_active' => ['nullable','boolean'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $data = $v->validated(); + $data['group_id'] = $group->id; + $item = TenantOptionValue::create($data); + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [GET] 옵션 값 단건 조회 + */ + public static function show(int $groupId, int $id) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $item = TenantOptionValue::where('group_id', $group->id)->find($id); + if (!$item) { + return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404); + } + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [PATCH] 옵션 값 수정 + */ + public static function update(int $groupId, int $id, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $item = TenantOptionValue::where('group_id', $group->id)->find($id); + if (!$item) { + return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'value_key' => [ + 'sometimes','string','max:64','alpha_dash', + Rule::unique('tenant_option_values', 'value_key') + ->where(fn ($q) => $q->where('group_id', $group->id)) + ->ignore($item->id), + ], + 'value_label' => ['sometimes','string','max:100'], + 'sort_order' => ['nullable','integer'], + 'is_active' => ['nullable','boolean'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $data = $v->validated(); + if (empty($data)) { + return ApiResponse::error('수정할 항목이 없습니다.', 422); + } + + $item->fill($data)->save(); + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [DELETE] 옵션 값 삭제 + * - TODO: 실제 프로필/필드설정에서 value_key 참조 여부 체크가 필요하면 여기서 차단 + */ + public static function destroy(int $groupId, int $id) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $item = TenantOptionValue::where('group_id', $group->id)->find($id); + if (!$item) { + return ApiResponse::error('옵션 값을 찾을 수 없습니다.', 404); + } + + // TODO: 참조 무결성 검사(필요 시 구현) + $item->delete(); + + return ApiResponse::response('result', ['deleted' => true]); + } + + /** + * [PATCH] 정렬 순서 일괄 변경 + * params: items: [{id, sort_order}, ...] + */ + public static function reorder(int $groupId, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $group = self::loadGroup($tenantId, $groupId); + if (!$group) { + return ApiResponse::error('옵션 그룹을 찾을 수 없습니다.', 404); + } + + $v = Validator::make($params, [ + 'items' => ['required','array','min:1'], + 'items.*.id' => ['required','integer'], + 'items.*.sort_order' => ['required','integer'], + ]); + + if ($v->fails()) { + return ApiResponse::error($v->errors()->first(), 422); + } + + $rows = $v->validated()['items']; + + try { + DB::transaction(function () use ($group, $rows) { + foreach ($rows as $r) { + TenantOptionValue::where('group_id', $group->id) + ->where('id', $r['id']) + ->update(['sort_order' => (int) $r['sort_order']]); + } + }); + } catch (\Throwable $e) { + return ApiResponse::error('정렬 순서 저장 중 오류가 발생했습니다.', 500); + } + + return ApiResponse::response('result', ['reordered' => true]); + } +} diff --git a/app/Services/TenantUserProfileService.php b/app/Services/TenantUserProfileService.php new file mode 100644 index 0000000..1f010e1 --- /dev/null +++ b/app/Services/TenantUserProfileService.php @@ -0,0 +1,194 @@ + [def, setting] */ + protected static function effectiveFieldMap(int $tenantId): array + { + $defs = SettingFieldDef::all()->keyBy('field_key'); + $settings = TenantFieldSetting::where('tenant_id', $tenantId)->get()->keyBy('field_key'); + + $map = []; + foreach ($defs as $key => $def) { + $s = $settings->get($key); + $enabled = $s ? (bool)$s->enabled : (bool)$def->is_core; + if (!$enabled) continue; + $map[$key] = ['def' => $def, 'setting' => $s]; + } + return $map; + } + + /** + * [GET] 프로필 목록 (페이징) + * params: per_page?, q? + */ + public static function index(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + + $per = (int)($params['per_page'] ?? $params['size'] ?? 20); + $q = TenantUserProfile::where('tenant_id', $tenantId) + ->with(['user:id,name,email']); + + if (!empty($params['q'])) { + $kw = $params['q']; + $q->where(function ($w) use ($kw) { + $w->where('display_name', 'like', "%{$kw}%") + ->orWhere('json_extra->employee_no', 'like', "%{$kw}%"); + }); + } + + $page = isset($params['page']) ? (int)$params['page'] : null; + $data = $q->orderByDesc('id')->paginate($per, ['*'], 'page', $page); + + return ApiResponse::response('result', $data); + } + + /** + * [GET] 프로필 단건 조회 + */ + public static function show(int $userId) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + if (!$userId) { + return ApiResponse::error('userId가 올바르지 않습니다.', 422); + } + + $item = TenantUserProfile::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->with(['user:id,name,email']) + ->first(); + + if (!$item) { + return ApiResponse::error('프로필을 찾을 수 없습니다.', 404); + } + + return ApiResponse::response('result', $item->toArray()); + } + + /** + * [PATCH] 관리자 수정: enabled 필드만 write-back + * - 입력 payload는 field_key: value 포맷(예: {"position":"manager","employee_no":"A-001"}) + */ + public static function update(int $userId, array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + if (!$userId) { + return ApiResponse::error('userId가 올바르지 않습니다.', 422); + } + + // (선택) 간단 유효성: 최소 1개 키 존재 + if (empty($params) || !is_array($params)) { + return ApiResponse::error('수정할 항목이 없습니다.', 422); + } + + $fields = self::effectiveFieldMap($tenantId); + $profile = TenantUserProfile::firstOrCreate(['tenant_id' => $tenantId, 'user_id' => $userId]); + + try { + DB::transaction(function () use ($fields, $profile, $params) { + foreach ($fields as $key => $meta) { + if (!array_key_exists($key, $params)) continue; // 입력이 없으면 스킵 + + $def = $meta['def']; + $value = $params[$key]; + + switch ($def->storage_area) { + case 'tenant_profile': + if ($def->storage_key) { + $profile->{$def->storage_key} = $value; + } + break; + + case 'tenant_profile_json': + $jsonKey = $def->storage_key ?: $key; + $extra = is_array($profile->json_extra) ? $profile->json_extra : (array) json_decode((string)$profile->json_extra, true); + $extra[$jsonKey] = $value; + $profile->json_extra = $extra; + break; + + case 'users': + // 정책상 기본 비권장: 무시 + // 필요 시 UsersService 등으로 화이트리스트 업데이트 구현 + break; + } + } + $profile->save(); + }); + } catch (\Throwable $e) { + return ApiResponse::error('프로필 저장 중 오류가 발생했습니다.', 500); + } + + $fresh = TenantUserProfile::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->with(['user:id,name,email']) + ->first(); + + return ApiResponse::response('result', $fresh ? $fresh->toArray() : null); + } + + /** + * [GET] 내 프로필 조회 + */ + public static function me(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + $userId = auth()->id(); + if (!$userId) { + return ApiResponse::error('인증 정보가 없습니다.', 401); + } + + $item = TenantUserProfile::where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->with(['user:id,name,email']) + ->first(); + + // 없으면 null 반환(스펙에 따라 404로 바꾸려면 위와 동일 처리) + return ApiResponse::response('result', $item ? $item->toArray() : null); + } + + /** + * [PATCH] 내 프로필 수정 + * - enabled 필드만 반영 (관리자 수정과 동일 로직) + */ + public static function updateMe(array $params = []) + { + $tenantId = self::tenantId(); + if (!$tenantId) { + return ApiResponse::error('활성 테넌트가 없습니다.', 400); + } + $userId = auth()->id(); + if (!$userId) { + return ApiResponse::error('인증 정보가 없습니다.', 401); + } + return self::update($userId, $params); + } +} diff --git a/app/Swagger/v1/FieldProfileApi.php b/app/Swagger/v1/FieldProfileApi.php new file mode 100644 index 0000000..efd5807 --- /dev/null +++ b/app/Swagger/v1/FieldProfileApi.php @@ -0,0 +1,572 @@ +name('departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지) }); + + // 테넌트 필드 설정 + Route::prefix('fields')->group(function () { + Route::get ('', [TenantFieldSettingController::class, 'index']); // 목록(효과값) + Route::put ('/bulk', [TenantFieldSettingController::class, 'bulkUpsert']); // 대량 저장 + Route::patch ('/{key}', [TenantFieldSettingController::class, 'updateOne']); // 단건 수정 + }); + + // 옵션 그룹/값 + Route::prefix('opt-groups')->group(function () { + Route::get ('', [TenantOptionGroupController::class, 'index']); + Route::post ('', [TenantOptionGroupController::class, 'store']); + Route::get ('/{id}', [TenantOptionGroupController::class, 'show']); + Route::patch ('/{id}', [TenantOptionGroupController::class, 'update']); + Route::delete('/{id}', [TenantOptionGroupController::class, 'destroy']); + + Route::get ('/{gid}/values', [TenantOptionValueController::class, 'index']); + Route::post ('/{gid}/values', [TenantOptionValueController::class, 'store']); + Route::get ('/{gid}/values/{id}', [TenantOptionValueController::class, 'show']); + Route::patch ('/{gid}/values/{id}', [TenantOptionValueController::class, 'update']); + Route::delete ('/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy']); + Route::patch ('/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder']); // [{id,sort_order}] + }); + + // 회원 프로필(테넌트 기준) + Route::prefix('profiles')->group(function () { + Route::get ('', [TenantUserProfileController::class, 'index']); // 목록 + Route::get ('/{userId}', [TenantUserProfileController::class, 'show']); // 단건 + Route::patch ('/{userId}', [TenantUserProfileController::class, 'update']); // 수정(관리자) + Route::get ('/me', [TenantUserProfileController::class, 'me']); // 내 프로필 + Route::patch ('/me', [TenantUserProfileController::class, 'updateMe']); // 내 정보 수정 + }); + }); });