Files
BarangaySystem/app/Http/Controllers/Support/ChapterController.php
Jonathan Sykes fbb7e3ff37
Some checks failed
tests / PHP 8.2 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.3 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.4 (swoole-6.0) (push) Has been cancelled
feat: implement barangay system phases 2-14
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
2026-06-07 03:09:09 +08:00

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;
}
}