Complete adaptation from BukidBountyApp to Philippine barangay governance: - Barangay models: Resident, Household, HouseholdMember, Blotter, BlotterHearing, DocumentRequest, RequestPayment, RequestType, BarangayProject, BarangayBudget - Controllers: ResidentController, HouseholdController, BlotterController, BlotterHearingController, DocumentRequestController, RequestTypeController, ProjectController, BudgetController, QRPHController, AdminConsoleController, UserController, FileController, ChapterController, LoginController - Vue pages: Home, ManageResidents, ResidentProfile, ManageHouseholds, ManageBlotters, BlotterDetail, RequestDocument, ManageDocumentRequests, DocumentRequestDetail, ManageRequestTypes, ManageProjects, BudgetLedger, AdminConsole - Barangay roles: PunongBarangay, Kagawad, Secretary, Treasurer, SK, Tanod, BHW, Staff, Resident - UserPermissions matrix rewritten with barangay-specific permission mappings - VueRouteMap replaced with barangay SPA routes - UserActions enum references corrected across all controllers - Removed all market/cooperative/POS/subscription code and models
1154 lines
43 KiB
PHP
1154 lines
43 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Support;
|
|
|
|
use App\Http\Controllers\Helpers\ResponseHelper;
|
|
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
|
use App\Enums\UserTypes;
|
|
use App\Enums\UserActions;
|
|
use App\Models\Chapter;
|
|
use App\Models\ChapterMember;
|
|
use App\Models\User;
|
|
use App\Models\SystemSetting;
|
|
use App\Support\IslandGroupHelper;
|
|
use App\Support\SystemSettingsHelper;
|
|
use Hypervel\Http\Request;
|
|
use Hypervel\Support\Facades\Auth;
|
|
use Hypervel\Support\Facades\Hash;
|
|
use Hypervel\Support\Facades\Validator;
|
|
use Hypervel\Support\Str;
|
|
|
|
class ChapterController
|
|
{
|
|
/** Child level mapping for chapter hierarchy. */
|
|
private const CHILD_LEVELS = [
|
|
'national' => ['region'],
|
|
'region' => ['province'],
|
|
'province' => ['city', 'municipal'],
|
|
'city' => ['barangay'],
|
|
'municipal' => ['barangay'],
|
|
'barangay' => [],
|
|
];
|
|
|
|
private const OFFICER_ROLES = ['PRESIDENT', 'VICE_PRESIDENT', 'SECRETARY', 'TREASURER', 'AUDITOR', 'BOARD_MEMBER'];
|
|
|
|
/** Big3 / Coordinator may act org-wide. */
|
|
private function isAdminCaller($acctType): bool
|
|
{
|
|
return in_array($acctType, [
|
|
UserTypes::SUPER_ADMIN,
|
|
UserTypes::PUNONG_BARANGAY,
|
|
UserTypes::KAGAWAD,
|
|
UserTypes::SECRETARY,
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* Resolve the caller's primary (highest) active chapter membership row,
|
|
* joined with chapter columns. Returns null if none.
|
|
*/
|
|
private function resolveCallerChapter(int $userId)
|
|
{
|
|
return ChapterMember::join('chapters', 'chapters.id', 'chapter_members.chapter_id')
|
|
->where('chapter_members.user_id', $userId)
|
|
->where('chapter_members.is_active', true)
|
|
->orderByRaw("FIELD(chapters.level,'national','region','province','city','municipal','barangay')")
|
|
->select(
|
|
'chapter_members.*',
|
|
'chapters.id as c_id',
|
|
'chapters.name as c_name',
|
|
'chapters.level as c_level',
|
|
'chapters.hashkey as c_hashkey',
|
|
'chapters.location_key as c_location_key',
|
|
'chapters.cooperative_id as c_cooperative_id'
|
|
)
|
|
->first();
|
|
}
|
|
|
|
private function chapterOfficers(int $chapterId): array
|
|
{
|
|
return ChapterMember::join('users', 'users.id', 'chapter_members.user_id')
|
|
->where('chapter_members.chapter_id', $chapterId)
|
|
->where('chapter_members.is_active', true)
|
|
->whereNotNull('chapter_members.role')
|
|
->where('chapter_members.role', '!=', 'MEMBER')
|
|
->select('users.name as name', 'users.fullname as fullname', 'chapter_members.role as role', 'chapter_members.position as position')
|
|
->get()
|
|
->map(fn ($r) => [
|
|
'name' => $r->fullname ?: $r->name,
|
|
'role' => $r->role,
|
|
'position' => $r->position,
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* Collect all chapter IDs in the subtree rooted at $rootId, scoped to $cooperativeId.
|
|
*/
|
|
private function subtreeChapterIds(int $rootId, ?int $cooperativeId): array
|
|
{
|
|
$ids = [$rootId];
|
|
$frontier = [$rootId];
|
|
while (!empty($frontier)) {
|
|
$q = Chapter::whereIn('parent_id', $frontier)->where('is_active', true);
|
|
if ($cooperativeId !== null) {
|
|
$q->where('cooperative_id', $cooperativeId);
|
|
}
|
|
$children = $q->pluck('id')->map(fn ($v) => (int) $v)->all();
|
|
$children = array_values(array_diff($children, $ids));
|
|
if (empty($children)) {
|
|
break;
|
|
}
|
|
$ids = array_merge($ids, $children);
|
|
$frontier = $children;
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Get hierarchy data for a given chapter level.
|
|
* If chapter_id is null, returns national-level (all regions).
|
|
* Otherwise returns children of the given chapter with member counts and leaders.
|
|
*/
|
|
public function hierarchy(Request $request)
|
|
{
|
|
$chapterId = $request->input('chapter_id');
|
|
|
|
if (!$chapterId) {
|
|
// Return all regions under national
|
|
$national = Chapter::where('level', 'national')->where('location_key', 'philippines')->first();
|
|
if (!$national) {
|
|
return response()->json(['chapters' => [], 'current' => null, 'breadcrumb' => []]);
|
|
}
|
|
$chapters = $national->children()
|
|
->where('is_active', true)
|
|
->withCount('activeMembers')
|
|
->with(['leaders.user'])
|
|
->get()
|
|
->map(fn($c) => $this->formatChapter($c))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'chapters' => $chapters,
|
|
'current' => null,
|
|
'breadcrumb' => [],
|
|
]);
|
|
}
|
|
|
|
$current = Chapter::withCount('activeMembers')->with(['leaders.user'])->find($chapterId);
|
|
if (!$current) {
|
|
return ResponseHelper::returnError('Chapter not found', 404);
|
|
}
|
|
|
|
$chapters = $current->children()
|
|
->where('is_active', true)
|
|
->withCount('activeMembers')
|
|
->with(['leaders.user', 'children' => fn($q) => $q->where('is_active', true)])
|
|
->get()
|
|
->map(fn($c) => $this->formatChapter($c))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'chapters' => $chapters,
|
|
'current' => $this->formatChapter($current),
|
|
'breadcrumb' => $this->buildBreadcrumb($current),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get member dots for the map — returns lat/lng counts per chapter at requested level.
|
|
*/
|
|
public function mapData(Request $request)
|
|
{
|
|
$level = $request->input('level', 'region');
|
|
$parentId = $request->input('parent_id');
|
|
|
|
$query = Chapter::where('level', $level)
|
|
->where('is_active', true)
|
|
->withCount('activeMembers');
|
|
|
|
if ($parentId) {
|
|
$query->where('parent_id', $parentId);
|
|
}
|
|
|
|
$chapters = $query->get()
|
|
->filter(fn($c) => $c->lat && $c->lng)
|
|
->map(function ($chapter) {
|
|
return [
|
|
'id' => $chapter->id,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
'lat' => $chapter->lat,
|
|
'lng' => $chapter->lng,
|
|
'count' => $chapter->active_members_count,
|
|
];
|
|
})
|
|
->values();
|
|
|
|
return response()->json(['chapters' => $chapters]);
|
|
}
|
|
|
|
/**
|
|
* Get members for a specific chapter (paginated).
|
|
*/
|
|
public function members(Request $request)
|
|
{
|
|
$chapterId = $request->input('chapter_id');
|
|
if (!$chapterId) {
|
|
return ResponseHelper::returnError('chapter_id required', 400);
|
|
}
|
|
|
|
$members = ChapterMember::with(['user', 'user.userInfo'])
|
|
->where('chapter_id', $chapterId)
|
|
->where('is_active', true)
|
|
->get()
|
|
->map(function ($cm) {
|
|
$user = $cm->user;
|
|
$info = $user?->userInfo;
|
|
return [
|
|
'hashkey' => $user?->hashkey,
|
|
'name' => $user?->fullname ?? $user?->name,
|
|
'position' => $cm->position,
|
|
'photo' => $user?->photourl,
|
|
'region' => $info?->region,
|
|
'province' => $info?->province,
|
|
'city' => $info?->city,
|
|
'barangay' => $info?->barangay,
|
|
'is_manual' => $cm->is_manual_override,
|
|
];
|
|
});
|
|
|
|
return response()->json(['members' => $members]);
|
|
}
|
|
|
|
/**
|
|
* Assign or update a member's position / chapter assignment.
|
|
*/
|
|
public function assignMember(Request $request)
|
|
{
|
|
$validator = Validator::make($request->all(), [
|
|
'user_hashkey' => 'required|string',
|
|
'chapter_id' => 'required|integer',
|
|
'position' => 'nullable|string',
|
|
'is_manual' => 'boolean',
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return ResponseHelper::returnError('Validation failed', 422, $validator->errors());
|
|
}
|
|
|
|
$user = User::where('hashkey', $request->user_hashkey)->first();
|
|
if (!$user) {
|
|
return ResponseHelper::returnError('User not found', 404);
|
|
}
|
|
|
|
$chapter = Chapter::find($request->chapter_id);
|
|
if (!$chapter) {
|
|
return ResponseHelper::returnError('Chapter not found', 404);
|
|
}
|
|
|
|
$existing = ChapterMember::where('user_id', $user->id)->where('chapter_id', $chapter->id)->first();
|
|
|
|
if ($existing) {
|
|
$existing->update([
|
|
'position' => $request->position,
|
|
'is_manual_override' => $request->input('is_manual', true),
|
|
'assigned_by' => Auth::id(),
|
|
'assigned_at' => now(),
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
} else {
|
|
ChapterMember::create([
|
|
'hashkey' => (string) \Hypervel\Support\Str::uuid(),
|
|
'user_id' => $user->id,
|
|
'chapter_id' => $chapter->id,
|
|
'position' => $request->position,
|
|
'is_manual_override' => $request->input('is_manual', true),
|
|
'is_active' => true,
|
|
'assigned_by' => Auth::id(),
|
|
'assigned_at' => now(),
|
|
'created_by' => Auth::id(),
|
|
]);
|
|
}
|
|
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Remove a member from a chapter assignment.
|
|
*/
|
|
public function removeMember(Request $request)
|
|
{
|
|
$chapterMember = ChapterMember::where('hashkey', $request->input('hashkey'))->first();
|
|
if (!$chapterMember) {
|
|
return ResponseHelper::returnError('Member assignment not found', 404);
|
|
}
|
|
|
|
$chapterMember->update(['is_active' => false, 'updated_by' => Auth::id()]);
|
|
return response()->json(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* Trigger auto-assignment for all users based on their current addresses.
|
|
*/
|
|
public function syncAllAutoAssignments()
|
|
{
|
|
$count = 0;
|
|
UserInfo::whereNotNull('region')->chunk(100, function ($infos) use (&$count) {
|
|
foreach ($infos as $info) {
|
|
Chapter::autoAssignUser($info->user_id);
|
|
$count++;
|
|
}
|
|
});
|
|
|
|
return response()->json(['synced' => $count]);
|
|
}
|
|
|
|
/**
|
|
* Get available positions from system settings.
|
|
*/
|
|
public function positions()
|
|
{
|
|
$positions = SystemSetting::getValue('chapter_positions', json_encode(['Director', 'Coordinator', 'Officer', 'Secretary', 'Treasurer', 'Auditor', 'Member']));
|
|
if (is_string($positions)) {
|
|
$positions = json_decode($positions, true) ?? [];
|
|
}
|
|
return response()->json(['positions' => $positions]);
|
|
}
|
|
|
|
/**
|
|
* Hierarchy scoped to members of the main organization, with a synthetic
|
|
* "island_group" level above region (Luzon / Visayas / Mindanao).
|
|
*
|
|
* Inputs:
|
|
* - island: 'luzon'|'visayas'|'mindanao' to drill into an island group
|
|
* - chapter_id: real chapter id to drill into a region/province/city
|
|
* If neither is provided, returns the 3 island groups with member counts.
|
|
*/
|
|
public function orgHierarchy(Request $request)
|
|
{
|
|
$org = SystemSettingsHelper::mainOrganization();
|
|
if (!$org) {
|
|
return response()->json([
|
|
'chapters' => [],
|
|
'current' => null,
|
|
'breadcrumb' => [],
|
|
'error' => 'No main organization configured.',
|
|
]);
|
|
}
|
|
|
|
$orgUserIds = $this->orgUserIds((int) $org->id);
|
|
$island = $request->input('island');
|
|
$chapterId = $request->input('chapter_id');
|
|
|
|
// Top level: 3 island groups
|
|
if (!$chapterId && !$island) {
|
|
$regionsByIsland = $this->regionIdsByIsland();
|
|
$chapters = [];
|
|
foreach (IslandGroupHelper::islands() as $isl) {
|
|
$regionIds = $regionsByIsland[$isl] ?? [];
|
|
$count = empty($regionIds) || empty($orgUserIds)
|
|
? 0
|
|
: ChapterMember::whereIn('chapter_id', $regionIds)
|
|
->whereIn('user_id', $orgUserIds)
|
|
->where('is_active', true)
|
|
->distinct('user_id')
|
|
->count('user_id');
|
|
$center = IslandGroupHelper::center($isl);
|
|
$chapters[] = [
|
|
'id' => null,
|
|
'island' => $isl,
|
|
'hashkey' => 'island:' . $isl,
|
|
'name' => IslandGroupHelper::label($isl),
|
|
'level' => 'island_group',
|
|
'location_key' => $isl,
|
|
'lat' => $center['lat'],
|
|
'lng' => $center['lng'],
|
|
'member_count' => (int) $count,
|
|
'leaders' => [],
|
|
'has_children' => !empty($regionIds),
|
|
];
|
|
}
|
|
return response()->json([
|
|
'chapters' => $chapters,
|
|
'current' => null,
|
|
'breadcrumb' => [],
|
|
]);
|
|
}
|
|
|
|
// Drilling into an island group: list its regions
|
|
if ($island && !$chapterId) {
|
|
$regionIds = $this->regionIdsByIsland()[$island] ?? [];
|
|
if (empty($regionIds)) {
|
|
return response()->json([
|
|
'chapters' => [],
|
|
'current' => $this->islandPseudoChapter($island),
|
|
'breadcrumb' => [],
|
|
]);
|
|
}
|
|
|
|
$chapters = Chapter::whereIn('id', $regionIds)
|
|
->where('is_active', true)
|
|
->with(['leaders.user', 'children' => fn($q) => $q->where('is_active', true)])
|
|
->get()
|
|
->map(fn($c) => $this->formatChapterScoped($c, $orgUserIds))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'chapters' => $chapters,
|
|
'current' => $this->islandPseudoChapter($island),
|
|
'breadcrumb' => [],
|
|
]);
|
|
}
|
|
|
|
// Drilling into a real chapter (region/province/city) — return its children
|
|
$current = Chapter::with(['leaders.user'])->find($chapterId);
|
|
if (!$current) {
|
|
return ResponseHelper::returnError('Chapter not found', 404);
|
|
}
|
|
|
|
$chapters = $current->children()
|
|
->where('is_active', true)
|
|
->with(['leaders.user', 'children' => fn($q) => $q->where('is_active', true)])
|
|
->get()
|
|
->map(fn($c) => $this->formatChapterScoped($c, $orgUserIds))
|
|
->values();
|
|
|
|
return response()->json([
|
|
'chapters' => $chapters,
|
|
'current' => $this->formatChapterScoped($current, $orgUserIds),
|
|
'breadcrumb' => $this->buildOrgBreadcrumb($current),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Map dots scoped to members of the main organization.
|
|
* Levels: 'island_group' (3 dots), 'region', 'province', 'city', 'barangay'.
|
|
*/
|
|
public function orgMapData(Request $request)
|
|
{
|
|
$org = SystemSettingsHelper::mainOrganization();
|
|
if (!$org) {
|
|
return response()->json(['chapters' => []]);
|
|
}
|
|
$orgUserIds = $this->orgUserIds((int) $org->id);
|
|
|
|
$level = $request->input('level', 'island_group');
|
|
$parentId = $request->input('parent_id');
|
|
$island = $request->input('island');
|
|
|
|
if ($level === 'island_group') {
|
|
$regionsByIsland = $this->regionIdsByIsland();
|
|
$dots = [];
|
|
foreach (IslandGroupHelper::islands() as $isl) {
|
|
$regionIds = $regionsByIsland[$isl] ?? [];
|
|
$count = empty($regionIds) || empty($orgUserIds)
|
|
? 0
|
|
: ChapterMember::whereIn('chapter_id', $regionIds)
|
|
->whereIn('user_id', $orgUserIds)
|
|
->where('is_active', true)
|
|
->distinct('user_id')
|
|
->count('user_id');
|
|
$center = IslandGroupHelper::center($isl);
|
|
$dots[] = [
|
|
'id' => null,
|
|
'island' => $isl,
|
|
'name' => IslandGroupHelper::label($isl),
|
|
'level' => 'island_group',
|
|
'lat' => $center['lat'],
|
|
'lng' => $center['lng'],
|
|
'count' => (int) $count,
|
|
];
|
|
}
|
|
return response()->json(['chapters' => $dots]);
|
|
}
|
|
|
|
$query = Chapter::where('level', $level)->where('is_active', true);
|
|
|
|
if ($parentId) {
|
|
$query->where('parent_id', $parentId);
|
|
} elseif ($level === 'region' && $island) {
|
|
$regionIds = $this->regionIdsByIsland()[$island] ?? [];
|
|
if (empty($regionIds)) return response()->json(['chapters' => []]);
|
|
$query->whereIn('id', $regionIds);
|
|
}
|
|
|
|
$chapters = $query->get()
|
|
->filter(fn($c) => $c->lat && $c->lng)
|
|
->map(function ($chapter) use ($orgUserIds) {
|
|
return [
|
|
'id' => $chapter->id,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
'lat' => $chapter->lat,
|
|
'lng' => $chapter->lng,
|
|
'count' => $this->scopedMemberCount($chapter->id, $orgUserIds),
|
|
];
|
|
})
|
|
->values();
|
|
|
|
return response()->json(['chapters' => $chapters]);
|
|
}
|
|
|
|
/**
|
|
* Org chart for the caller's chapter (officers + direct children),
|
|
* or — when a chapter_id is given — the officers of that specific chapter.
|
|
*/
|
|
public function getOrgChart(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
$acctType = $user->acct_type;
|
|
|
|
// Drill request: officers of a specific child chapter.
|
|
$requestedChapterId = $request->input('chapter_id');
|
|
if ($requestedChapterId) {
|
|
$chapter = Chapter::find($requestedChapterId);
|
|
if (!$chapter) {
|
|
return ResponseHelper::returnError('Chapter not found', 404);
|
|
}
|
|
return response()->json([
|
|
'chapter' => [
|
|
'id' => $chapter->id,
|
|
'hashkey' => $chapter->hashkey,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
],
|
|
'officers' => $this->chapterOfficers((int) $chapter->id),
|
|
]);
|
|
}
|
|
|
|
// Admin / coordinator: optional cooperative_id to view a coop's top chapter.
|
|
if ($this->isAdminCaller($acctType)) {
|
|
$cooperativeId = $request->input('cooperative_id');
|
|
$rootQuery = Chapter::where('is_active', true)
|
|
->orderByRaw("FIELD(level,'national','region','province','city','municipal','barangay')");
|
|
if ($cooperativeId) {
|
|
$rootQuery->where('cooperative_id', $cooperativeId);
|
|
}
|
|
$root = $rootQuery->first();
|
|
if (!$root) {
|
|
return response()->json(['own_chapter' => null, 'children' => []]);
|
|
}
|
|
return response()->json([
|
|
'own_chapter' => [
|
|
'id' => $root->id,
|
|
'hashkey' => $root->hashkey,
|
|
'name' => $root->name,
|
|
'level' => $root->level,
|
|
'location_key' => $root->location_key,
|
|
'officers' => $this->chapterOfficers((int) $root->id),
|
|
],
|
|
'children' => $this->childChapterRows((int) $root->id, $root->cooperative_id),
|
|
]);
|
|
}
|
|
|
|
$cm = $this->resolveCallerChapter((int) $user->id);
|
|
if (!$cm) {
|
|
return response()->json(['own_chapter' => null, 'children' => []]);
|
|
}
|
|
|
|
$ownChapter = [
|
|
'id' => (int) $cm->c_id,
|
|
'hashkey' => $cm->c_hashkey,
|
|
'name' => $cm->c_name,
|
|
'level' => $cm->c_level,
|
|
'location_key' => $cm->c_location_key,
|
|
'officers' => $this->chapterOfficers((int) $cm->c_id),
|
|
];
|
|
|
|
// COOP_MEMBER: own chapter + officers only, no children/member lists.
|
|
if ($acctType === UserTypes::RESIDENT) {
|
|
return response()->json(['own_chapter' => $ownChapter, 'children' => []]);
|
|
}
|
|
|
|
return response()->json([
|
|
'own_chapter' => $ownChapter,
|
|
'children' => $this->childChapterRows((int) $cm->c_id, $cm->c_cooperative_id),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Direct child chapters with active member counts (officers collapsed/empty).
|
|
*/
|
|
private function childChapterRows(int $parentId, $cooperativeId): array
|
|
{
|
|
$query = Chapter::where('parent_id', $parentId)->where('is_active', true);
|
|
if ($cooperativeId !== null) {
|
|
$query->where('cooperative_id', $cooperativeId);
|
|
}
|
|
return $query->withCount('activeMembers')->get()->map(fn ($c) => [
|
|
'id' => (int) $c->id,
|
|
'hashkey' => $c->hashkey,
|
|
'name' => $c->name,
|
|
'level' => $c->level,
|
|
'member_count' => (int) $c->active_members_count,
|
|
'officers' => [],
|
|
])->values()->all();
|
|
}
|
|
|
|
/**
|
|
* Caller's own chapter + direct children + cooperative + eligible (non-officer) members.
|
|
*/
|
|
public function getOfficerScope(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
|
|
$cm = $this->resolveCallerChapter((int) $user->id);
|
|
if (!$cm) {
|
|
return response()->json([
|
|
'own_chapter' => null,
|
|
'child_chapters' => [],
|
|
'cooperative' => null,
|
|
'eligible_members' => [],
|
|
]);
|
|
}
|
|
|
|
$coop = $cm->c_cooperative_id
|
|
? Organization::where('id', $cm->c_cooperative_id)->first(['hashkey', 'name'])
|
|
: null;
|
|
|
|
$childChapters = Chapter::where('parent_id', $cm->c_id)
|
|
->where('is_active', true)
|
|
->when($cm->c_cooperative_id !== null, fn ($q) => $q->where('cooperative_id', $cm->c_cooperative_id))
|
|
->withCount('activeMembers')
|
|
->get()
|
|
->map(fn ($c) => [
|
|
'id' => (int) $c->id,
|
|
'hashkey' => $c->hashkey,
|
|
'name' => $c->name,
|
|
'level' => $c->level,
|
|
'active_members_count' => (int) $c->active_members_count,
|
|
])->values()->all();
|
|
|
|
$eligible = ChapterMember::join('users', 'users.id', 'chapter_members.user_id')
|
|
->where('chapter_members.chapter_id', $cm->c_id)
|
|
->where('chapter_members.is_active', true)
|
|
->where(function ($q) {
|
|
$q->whereNull('chapter_members.role')->orWhere('chapter_members.role', 'MEMBER');
|
|
})
|
|
->select('users.hashkey as user_hashkey', 'users.name as name', 'users.fullname as fullname', 'chapter_members.role as role')
|
|
->get()
|
|
->map(fn ($r) => [
|
|
'user_hashkey' => $r->user_hashkey,
|
|
'name' => $r->fullname ?: $r->name,
|
|
'role' => $r->role,
|
|
])->values()->all();
|
|
|
|
return response()->json([
|
|
'own_chapter' => [
|
|
'id' => (int) $cm->c_id,
|
|
'hashkey' => $cm->c_hashkey,
|
|
'name' => $cm->c_name,
|
|
'level' => $cm->c_level,
|
|
],
|
|
'child_chapters' => $childChapters,
|
|
'cooperative' => $coop ? ['hashkey' => $coop->hashkey, 'name' => $coop->name] : null,
|
|
'eligible_members' => $eligible,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Name-only member search across the caller's chapter subtree.
|
|
* Returns ONLY name, role, chapter_name — no contact/identifying fields.
|
|
*/
|
|
public function memberSearch(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::ManageChapterMembers)) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'query' => 'required|string|min:2',
|
|
]);
|
|
if ($validator->fails()) {
|
|
return ResponseHelper::returnError('Validation failed', 422, $validator->errors());
|
|
}
|
|
|
|
$query = trim((string) $request->input('query'));
|
|
|
|
$scopeIds = [];
|
|
if ($this->isAdminCaller($user->acct_type)) {
|
|
// Admins: search all active chapters.
|
|
$scopeIds = Chapter::where('is_active', true)->pluck('id')->map(fn ($v) => (int) $v)->all();
|
|
} else {
|
|
$cm = $this->resolveCallerChapter((int) $user->id);
|
|
if (!$cm) {
|
|
return response()->json(['members' => []]);
|
|
}
|
|
$scopeIds = $this->subtreeChapterIds((int) $cm->c_id, $cm->c_cooperative_id);
|
|
}
|
|
|
|
if (empty($scopeIds)) {
|
|
return response()->json(['members' => []]);
|
|
}
|
|
|
|
$members = User::join('chapter_members', 'users.id', 'chapter_members.user_id')
|
|
->join('chapters', 'chapters.id', 'chapter_members.chapter_id')
|
|
->whereIn('chapter_members.chapter_id', $scopeIds)
|
|
->where('chapter_members.is_active', true)
|
|
->where('users.name', 'LIKE', "%{$query}%")
|
|
->select('users.name as name', 'chapter_members.role as role', 'chapters.name as chapter_name')
|
|
->distinct()
|
|
->limit(30)
|
|
->get()
|
|
->map(fn ($r) => [
|
|
'name' => $r->name,
|
|
'role' => $r->role,
|
|
'chapter_name' => $r->chapter_name,
|
|
])->values()->all();
|
|
|
|
return response()->json(['members' => $members]);
|
|
}
|
|
|
|
/**
|
|
* Move an eligible member of the caller's chapter to a direct child chapter as an officer.
|
|
*/
|
|
public function assignOfficer(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::AssignChapterOfficer)) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'member_user_hashkey' => 'required|string',
|
|
'child_chapter_id' => 'required|integer',
|
|
'role' => 'required|string|in:' . implode(',', self::OFFICER_ROLES),
|
|
]);
|
|
if ($validator->fails()) {
|
|
return ResponseHelper::returnError('Validation failed', 422, $validator->errors());
|
|
}
|
|
|
|
$cm = $this->resolveCallerChapter((int) $user->id);
|
|
if (!$cm && !$this->isAdminCaller($user->acct_type)) {
|
|
return ResponseHelper::returnError('You are not assigned to a chapter.', 422);
|
|
}
|
|
|
|
$member = User::where('hashkey', $request->input('member_user_hashkey'))->first();
|
|
if (!$member) {
|
|
return ResponseHelper::returnError('Member not found', 404);
|
|
}
|
|
|
|
$childChapter = Chapter::find($request->input('child_chapter_id'));
|
|
if (!$childChapter) {
|
|
return ResponseHelper::returnError('Child chapter not found', 404);
|
|
}
|
|
|
|
// Eligibility B — child must be a direct child of caller's chapter (admins exempt).
|
|
if (!$this->isAdminCaller($user->acct_type)) {
|
|
if ((int) $childChapter->parent_id !== (int) $cm->c_id
|
|
|| (int) $childChapter->cooperative_id !== (int) $cm->c_cooperative_id) {
|
|
return ResponseHelper::returnError('Selected chapter is not a direct sub-chapter of your chapter.', 422);
|
|
}
|
|
}
|
|
|
|
// Eligibility A — member must be an active non-officer in the caller's chapter.
|
|
$parentChapterId = $this->isAdminCaller($user->acct_type) ? (int) $childChapter->parent_id : (int) $cm->c_id;
|
|
$parentMembership = ChapterMember::where('user_id', $member->id)
|
|
->where('chapter_id', $parentChapterId)
|
|
->where('is_active', true)
|
|
->first();
|
|
if (!$parentMembership) {
|
|
return ResponseHelper::returnError('Member is not part of the parent chapter.', 422);
|
|
}
|
|
if (!empty($parentMembership->role) && $parentMembership->role !== 'MEMBER') {
|
|
return ResponseHelper::returnError('Member is already an officer.', 422);
|
|
}
|
|
|
|
$role = $request->input('role');
|
|
|
|
// 1. Remove existing parent-chapter membership (MOVE, not copy).
|
|
$parentMembership->update(['is_active' => false, 'updated_by' => Auth::id()]);
|
|
|
|
// 2. Upsert child-chapter officer membership.
|
|
$existingChild = ChapterMember::where('user_id', $member->id)
|
|
->where('chapter_id', $childChapter->id)
|
|
->first();
|
|
if ($existingChild) {
|
|
$existingChild->update([
|
|
'role' => $role,
|
|
'position' => $role,
|
|
'is_manual_override' => true,
|
|
'assigned_by' => Auth::id(),
|
|
'assigned_at' => now(),
|
|
'is_active' => true,
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
} else {
|
|
ChapterMember::create([
|
|
'hashkey' => Str::random(64),
|
|
'user_id' => $member->id,
|
|
'chapter_id' => $childChapter->id,
|
|
'role' => $role,
|
|
'position' => $role,
|
|
'is_manual_override' => true,
|
|
'assigned_by' => Auth::id(),
|
|
'assigned_at' => now(),
|
|
'is_active' => true,
|
|
'created_by' => Auth::id(),
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
}
|
|
|
|
// 3. Upgrade acct_type if currently a plain coop member.
|
|
if ($member->acct_type === UserTypes::RESIDENT) {
|
|
$member->acct_type = UserTypes::KAGAWAD;
|
|
$member->save();
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "Assigned {$member->name} as {$role} to {$childChapter->name}.",
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Create a chapter one level below the caller's chapter, scoped to its cooperative.
|
|
*/
|
|
public function createChapter(Request $request)
|
|
{
|
|
$user = Auth::user();
|
|
if (!$user) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::ManageChapterMembers)) {
|
|
return ResponseHelper::returnUnauthorized();
|
|
}
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'name' => 'required|string|max:255',
|
|
'location_key' => 'required|string|max:255',
|
|
'lat' => 'nullable|numeric',
|
|
'lng' => 'nullable|numeric',
|
|
]);
|
|
if ($validator->fails()) {
|
|
return ResponseHelper::returnError('Validation failed', 422, $validator->errors());
|
|
}
|
|
|
|
$cm = $this->resolveCallerChapter((int) $user->id);
|
|
if (!$cm) {
|
|
return ResponseHelper::returnError('You are not assigned to a chapter.', 422);
|
|
}
|
|
|
|
$childLevels = self::CHILD_LEVELS[$cm->c_level] ?? [];
|
|
if (empty($childLevels)) {
|
|
return ResponseHelper::returnError('Cannot create sub-chapters at barangay level.', 422);
|
|
}
|
|
$childLevel = $childLevels[0];
|
|
|
|
$chapter = Chapter::create([
|
|
'hashkey' => \Ramsey\Uuid\Uuid::uuid4()->toString(),
|
|
'name' => trim((string) $request->input('name')),
|
|
'level' => $childLevel,
|
|
'parent_id' => (int) $cm->c_id,
|
|
'cooperative_id' => $cm->c_cooperative_id,
|
|
'location_key' => strtolower(trim((string) $request->input('location_key'))),
|
|
'lat' => $request->input('lat'),
|
|
'lng' => $request->input('lng'),
|
|
'is_active' => true,
|
|
'created_by' => Auth::id(),
|
|
'updated_by' => Auth::id(),
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'chapter' => [
|
|
'hashkey' => $chapter->hashkey,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Public: get minimal chapter + cooperative info for a registration link.
|
|
*/
|
|
public function publicGetChapter(Request $request, string $hkey)
|
|
{
|
|
$chapter = Chapter::where('hashkey', $hkey)->where('is_active', true)->first();
|
|
if (!$chapter) {
|
|
return response()->json(['success' => false, 'message' => 'Chapter not found'], 404);
|
|
}
|
|
$coop = $chapter->cooperative_id
|
|
? Organization::where('id', $chapter->cooperative_id)->first(['name'])
|
|
: null;
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'chapter' => ['name' => $chapter->name, 'level' => $chapter->level],
|
|
'cooperative' => ['name' => $coop?->name],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Public: register a new coop member directly into a chapter via a shared link.
|
|
*/
|
|
public function publicRegisterToChapter(Request $request)
|
|
{
|
|
$hkey = $request->input('chapter_hash');
|
|
if (!$hkey) {
|
|
return ResponseHelper::returnIncorrectDetails();
|
|
}
|
|
|
|
$chapter = Chapter::where('hashkey', $hkey)->where('is_active', true)->first();
|
|
if (!$chapter) {
|
|
return response()->json(['success' => false, 'message' => 'Chapter not found'], 404);
|
|
}
|
|
|
|
$cooperative = $chapter->cooperative_id
|
|
? Organization::where('id', $chapter->cooperative_id)->where('type', 'COOPERATIVE')->first()
|
|
: null;
|
|
|
|
$validator = Validator::make($request->all(), [
|
|
'name' => 'required|string|max:255',
|
|
'username' => 'required|string|max:255|unique:users,username',
|
|
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
|
|
'password' => 'required|string|min:6',
|
|
]);
|
|
if ($validator->fails()) {
|
|
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
|
|
}
|
|
$validated = $validator->validated();
|
|
|
|
$parentUser = User::where('id', $chapter->created_by)->first()
|
|
?? User::where('acct_type', UserTypes::SECRETARY->value)->first()
|
|
?? User::orderBy('id')->first();
|
|
if (!$parentUser) {
|
|
return response()->json(['success' => false, 'message' => 'No valid parent user found'], 500);
|
|
}
|
|
|
|
$user = new User();
|
|
$user->username = $validated['username'];
|
|
$user->name = $validated['name'];
|
|
$user->mobile_number = $validated['mobile_number'];
|
|
$user->password = Hash::make($validated['password']);
|
|
$user->parentuid = $parentUser->id;
|
|
$user->acct_type = UserTypes::RESIDENT;
|
|
$user->active = true;
|
|
if ($cooperative) {
|
|
$settings = $user->settings ?? [];
|
|
if (!is_array($settings)) {
|
|
$settings = [];
|
|
}
|
|
$settings['cooperatives'] = [$cooperative->hashkey];
|
|
$user->settings = $settings;
|
|
}
|
|
$user->save();
|
|
|
|
// Link to cooperative.
|
|
if ($cooperative) {
|
|
CooperativeMember::create([
|
|
'hashkey' => Str::random(64),
|
|
'organization_id' => $cooperative->id,
|
|
'user_id' => $user->id,
|
|
'is_active' => true,
|
|
'created_by' => $user->id,
|
|
'updated_by' => $user->id,
|
|
]);
|
|
}
|
|
|
|
// Link to chapter.
|
|
ChapterMember::create([
|
|
'hashkey' => Str::random(64),
|
|
'user_id' => $user->id,
|
|
'chapter_id' => $chapter->id,
|
|
'is_manual_override' => false,
|
|
'is_active' => true,
|
|
'created_by' => $user->id,
|
|
'updated_by' => $user->id,
|
|
]);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'user_hashkey' => $user->hashkey,
|
|
'message' => 'Account created. You may now log in.',
|
|
], 201);
|
|
}
|
|
|
|
// --- Private helpers ---
|
|
|
|
private function formatChapter(Chapter $chapter): array
|
|
{
|
|
$memberCount = $chapter->active_members_count ?? $chapter->activeMembers()->count();
|
|
|
|
$leaders = $chapter->leaders->map(fn($cm) => [
|
|
'name' => $cm->user?->fullname ?? $cm->user?->name,
|
|
'position' => $cm->position,
|
|
'photo' => $cm->user?->photourl,
|
|
'hashkey' => $cm->user?->hashkey,
|
|
])->values()->toArray();
|
|
|
|
// Check if children already loaded, otherwise count
|
|
$hasChildren = $chapter->relationLoaded('children')
|
|
? $chapter->children->isNotEmpty()
|
|
: $chapter->children()->where('is_active', true)->exists();
|
|
|
|
return [
|
|
'id' => $chapter->id,
|
|
'hashkey' => $chapter->hashkey,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
'location_key' => $chapter->location_key,
|
|
'lat' => $chapter->lat,
|
|
'lng' => $chapter->lng,
|
|
'member_count' => (int) $memberCount,
|
|
'leaders' => $leaders,
|
|
'has_children' => (bool) $hasChildren,
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* IDs of users who are active CooperativeMember rows of the given organization.
|
|
* Returns [] if none — callers should treat that as "no scoped members".
|
|
*/
|
|
private function orgUserIds(int $organizationId): array
|
|
{
|
|
return CooperativeMember::where('organization_id', $organizationId)
|
|
->pluck('user_id')
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function scopedMemberCount(int $chapterId, array $orgUserIds): int
|
|
{
|
|
if (empty($orgUserIds)) return 0;
|
|
return (int) ChapterMember::where('chapter_id', $chapterId)
|
|
->where('is_active', true)
|
|
->whereIn('user_id', $orgUserIds)
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* Build a map of island_group → [region chapter ids] using IslandGroupHelper.
|
|
*/
|
|
private function regionIdsByIsland(): array
|
|
{
|
|
$map = [
|
|
IslandGroupHelper::LUZON => [],
|
|
IslandGroupHelper::VISAYAS => [],
|
|
IslandGroupHelper::MINDANAO => [],
|
|
];
|
|
$regions = Chapter::where('level', 'region')->where('is_active', true)->get(['id', 'name', 'location_key']);
|
|
foreach ($regions as $r) {
|
|
$island = IslandGroupHelper::fromRegion($r->location_key)
|
|
?? IslandGroupHelper::fromRegion($r->name);
|
|
if ($island && isset($map[$island])) {
|
|
$map[$island][] = (int) $r->id;
|
|
}
|
|
}
|
|
return $map;
|
|
}
|
|
|
|
private function islandPseudoChapter(string $island): array
|
|
{
|
|
$center = IslandGroupHelper::center($island);
|
|
return [
|
|
'id' => null,
|
|
'island' => $island,
|
|
'hashkey' => 'island:' . $island,
|
|
'name' => IslandGroupHelper::label($island),
|
|
'level' => 'island_group',
|
|
'location_key' => $island,
|
|
'lat' => $center['lat'],
|
|
'lng' => $center['lng'],
|
|
'member_count' => 0,
|
|
'leaders' => [],
|
|
'has_children' => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Same as formatChapter but member_count is scoped to org user ids.
|
|
* Leaders are filtered to those whose user is in the org.
|
|
*/
|
|
private function formatChapterScoped(Chapter $chapter, array $orgUserIds): array
|
|
{
|
|
$memberCount = $this->scopedMemberCount((int) $chapter->id, $orgUserIds);
|
|
|
|
$leaders = $chapter->leaders
|
|
->filter(fn($cm) => empty($orgUserIds) ? false : in_array((int) $cm->user_id, $orgUserIds, true))
|
|
->map(fn($cm) => [
|
|
'name' => $cm->user?->fullname ?? $cm->user?->name,
|
|
'position' => $cm->position,
|
|
'photo' => $cm->user?->photourl,
|
|
'hashkey' => $cm->user?->hashkey,
|
|
])->values()->toArray();
|
|
|
|
$hasChildren = $chapter->relationLoaded('children')
|
|
? $chapter->children->isNotEmpty()
|
|
: $chapter->children()->where('is_active', true)->exists();
|
|
|
|
return [
|
|
'id' => $chapter->id,
|
|
'hashkey' => $chapter->hashkey,
|
|
'name' => $chapter->name,
|
|
'level' => $chapter->level,
|
|
'location_key' => $chapter->location_key,
|
|
'lat' => $chapter->lat,
|
|
'lng' => $chapter->lng,
|
|
'member_count' => (int) $memberCount,
|
|
'leaders' => $leaders,
|
|
'has_children' => (bool) $hasChildren,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Breadcrumb walking up to the region, then prepending the island group.
|
|
*/
|
|
private function buildOrgBreadcrumb(Chapter $chapter): array
|
|
{
|
|
$crumbs = [];
|
|
$current = $chapter;
|
|
while ($current->parent_id) {
|
|
$current = $current->parent;
|
|
if (!$current) break;
|
|
if ($current->level === 'national') break;
|
|
array_unshift($crumbs, ['id' => $current->id, 'name' => $current->name, 'level' => $current->level]);
|
|
}
|
|
// Determine island from the topmost region in the trail (or the chapter itself).
|
|
$regionRow = collect($crumbs)->first(fn($c) => $c['level'] === 'region');
|
|
$regionName = $regionRow['name'] ?? ($chapter->level === 'region' ? $chapter->name : null);
|
|
$island = IslandGroupHelper::fromRegion($regionName);
|
|
if ($island) {
|
|
array_unshift($crumbs, [
|
|
'id' => null,
|
|
'island' => $island,
|
|
'name' => IslandGroupHelper::label($island),
|
|
'level' => 'island_group',
|
|
]);
|
|
}
|
|
return $crumbs;
|
|
}
|
|
|
|
private function buildBreadcrumb(Chapter $chapter): array
|
|
{
|
|
$breadcrumb = [];
|
|
$current = $chapter;
|
|
while ($current->parent_id) {
|
|
$current = $current->parent;
|
|
if (!$current) break;
|
|
array_unshift($breadcrumb, ['id' => $current->id, 'name' => $current->name, 'level' => $current->level]);
|
|
}
|
|
return $breadcrumb;
|
|
}
|
|
}
|