From 6eaf4be4d00dac8af9e79ea6062917bde7807f39 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 20:16:32 +0200 Subject: [PATCH 1/4] =?UTF-8?q?We=20added=20role-based=20visibility=20for?= =?UTF-8?q?=20surveys=20so=20that=20users=20only=20see=20and=20work=20with?= =?UTF-8?q?=20the=20surveys=20assigned=20to=20their=20role.=20Since=20user?= =?UTF-8?q?s=20have=20different=20responsibilities,=20showing=20all=20surv?= =?UTF-8?q?eys=20to=20everyone=20can=20be=20confusing=20and=20may=20expose?= =?UTF-8?q?=20data=20they=20shouldn=E2=80=99t=20access.=20Limiting=20visib?= =?UTF-8?q?ility=20will=20help=20keep=20things=20clear=20and=20ensure=20us?= =?UTF-8?q?ers=20only=20interact=20with=20the=20surveys=20relevant=20to=20?= =?UTF-8?q?them.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/V5/DTO/PostSearchFields.php | 18 ++++++ .../Modules/V5/DTO/SurveySearchFields.php | 17 ++++++ .../Post/EloquentPostRepository.php | 55 ++++++++++++++++--- .../Survey/EloquentSurveyRepository.php | 21 +++++++ 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/src/Ushahidi/Modules/V5/DTO/PostSearchFields.php b/src/Ushahidi/Modules/V5/DTO/PostSearchFields.php index 90c1804cc..0e3b2e898 100644 --- a/src/Ushahidi/Modules/V5/DTO/PostSearchFields.php +++ b/src/Ushahidi/Modules/V5/DTO/PostSearchFields.php @@ -44,6 +44,8 @@ class PostSearchFields extends SearchFields protected $center_point; protected $include_unstructured_posts; + protected $role; + // before ready protected $set; @@ -161,6 +163,12 @@ public function __construct(Request $request) $this->set = $this->getParameterAsArray($request->get('set')); $this->tags = $this->getParameterAsArray($request->get('tags')); + + if (Auth::user()) { + $this->role = Auth::user()->role; + } else { + $this->role = null; + } } @@ -315,4 +323,14 @@ public function includeUnstructuredPosts() { return $this->include_unstructured_posts; } + + public function role(): ?string + { + return $this->role; + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } } diff --git a/src/Ushahidi/Modules/V5/DTO/SurveySearchFields.php b/src/Ushahidi/Modules/V5/DTO/SurveySearchFields.php index 2e0fdf1f8..3cb61a0b8 100644 --- a/src/Ushahidi/Modules/V5/DTO/SurveySearchFields.php +++ b/src/Ushahidi/Modules/V5/DTO/SurveySearchFields.php @@ -3,6 +3,7 @@ namespace Ushahidi\Modules\V5\DTO; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; class SurveySearchFields extends SearchFields { @@ -12,15 +13,31 @@ class SurveySearchFields extends SearchFields protected $query; public $showUnknownForm; + private $role; public function __construct(Request $request) { $this->query = $request->query('q'); $this->showUnknownForm = $request->query('show_unknown_form', false); + if (Auth::user()) { + $this->role = Auth::user()->role; + } else { + $this->role = null; + } } public function q(): ?string { return $this->query; } + + public function role(): ?string + { + return $this->role; + } + + public function isAdmin(): bool + { + return $this->role === 'admin'; + } } diff --git a/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php b/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php index 6bba6d2ec..1a776ab1e 100644 --- a/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php +++ b/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php @@ -108,6 +108,33 @@ private function setSearchCondition(PostSearchFields $search_fields, $query, boo } } + if (!$search_fields->isAdmin()) { + $role = $search_fields->role(); + $query->where(function ($query) use ($role) { + $query->whereNull('posts.form_id') + ->orWhere(function ($formQuery) use ($role) { + $formQuery->whereNotNull('posts.form_id') + ->where(function ($roleQuery) use ($role) { + $roleQuery->whereNotExists(function ($subquery) { + $subquery->select(DB::raw(1)) + ->from('form_roles') + ->whereColumn('form_roles.form_id', 'posts.form_id'); + }); + + if ($role) { + $roleQuery->orWhereExists(function ($subquery) use ($role) { + $subquery->select(DB::raw(1)) + ->from('form_roles') + ->join('roles', 'roles.id', '=', 'form_roles.role_id') + ->whereColumn('form_roles.form_id', 'posts.form_id') + ->where('roles.name', '=', $role); + }); + } + }); + }); + }); + } + if (count($search_fields->user())) { $query->whereIn('posts.user_id', $search_fields->user()); } elseif ($search_fields->userNone()) { @@ -340,20 +367,30 @@ public function paginate( array $with = [] ): LengthAwarePaginator { $fields = $this->addPostsTableNamePrefix($fields); - // add the order field if not found - if (!in_array('posts.'.$paging->getOrderBy(), $fields)) { - $fields[] = 'posts.'.$paging->getOrderBy(); + $orderBy = $paging->getOrderBy(); + + $query = Post::query(); + + if ($orderBy === 'event_date') { + $query->select($fields) + ->selectRaw('COALESCE((SELECT MAX(value) FROM post_datetime WHERE post_id = posts.id), posts.post_date) as event_date') + ->orderBy('event_date', $paging->getOrder()); + } else { + // add the order field if not found + if (!in_array('posts.'.$orderBy, $fields)) { + $fields[] = 'posts.'.$orderBy; + } + $query->orderBy('posts.'.$orderBy, $paging->getOrder()); + + if (count($fields)) { + $query->select($fields); + } } - $query = Post::take($paging->getLimit()) - //->skip($paging->getSkip()) - ->orderBy('posts.'.$paging->getOrderBy(), $paging->getOrder()); + $query->take($paging->getLimit()); $query = $this->setSearchCondition($search_fields, $query); $query = $this->setGuestConditions($query); - if (count($fields)) { - $query->select($fields); - } if (count($with)) { $query->with($with); } diff --git a/src/Ushahidi/Modules/V5/Repository/Survey/EloquentSurveyRepository.php b/src/Ushahidi/Modules/V5/Repository/Survey/EloquentSurveyRepository.php index 0a0d7cb8e..82b631847 100644 --- a/src/Ushahidi/Modules/V5/Repository/Survey/EloquentSurveyRepository.php +++ b/src/Ushahidi/Modules/V5/Repository/Survey/EloquentSurveyRepository.php @@ -134,6 +134,27 @@ private function setSearchCondition(SurveySearchFields $survey_search_fields, $b $builder->where('name', 'LIKE', "%" . $survey_search_fields->q() . "%"); } + if (!$survey_search_fields->isAdmin()) { + $role = $survey_search_fields->role(); + $builder->where(function (Builder $query) use ($role) { + $query->whereNotExists(function ($subquery) { + $subquery->select(DB::raw(1)) + ->from('form_roles') + ->whereColumn('form_roles.form_id', 'forms.id'); + }); + + if ($role) { + $query->orWhereExists(function ($subquery) use ($role) { + $subquery->select(DB::raw(1)) + ->from('form_roles') + ->join('roles', 'roles.id', '=', 'form_roles.role_id') + ->whereColumn('form_roles.form_id', 'forms.id') + ->where('roles.name', '=', $role); + }); + } + }); + } + return $builder; } From e48a55c6fdba5af29461ea39ad13c05976333c81 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 20:37:59 +0200 Subject: [PATCH 2/4] Currently, when an existing post is updated, the user_id, author_realname, and author_email fields are either pulled from the incoming request or retained from the original post. This behavior needs to be revised so that ownership of the post is reassigned to the user performing the update. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By doing this, any user who updates a post becomes the recognized owner of that update. This change is important for maintaining accurate audit trails, ensuring proper ownership in multi-user environments, and keeping post metadata (such as real name and email) aligned with the individual who most recently modified or published the content—regardless of the original creator. --- .../V5/Actions/Post/Commands/UpdatePostCommand.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/Ushahidi/Modules/V5/Actions/Post/Commands/UpdatePostCommand.php b/src/Ushahidi/Modules/V5/Actions/Post/Commands/UpdatePostCommand.php index 42e2e7c8c..601c06cdf 100644 --- a/src/Ushahidi/Modules/V5/Actions/Post/Commands/UpdatePostCommand.php +++ b/src/Ushahidi/Modules/V5/Actions/Post/Commands/UpdatePostCommand.php @@ -58,19 +58,11 @@ public static function fromRequest(int $id, PostRequest $request, Post $current_ // dd($request->input()); $user = Auth::user(); - if (self::hasPermissionToUpdateUser($user)) { - $input['user_id'] = $request->has('user_id') - ? $request->input('user_id') : $current_post->user_id; - ; - } else { - $input['user_id'] = $current_post->user_id; - } + $input['user_id'] = $user->id; + $input['author_realname'] = $user->realname; + $input['author_email'] = $user->email; $input['slug'] = $request->input('slug') ? Post::makeSlug($request->input('slug')) : $current_post->slug; - $input['author_email'] = $request->has('author_email') - ? $request->input('author_email') : $current_post->author_email; - $input['author_realname'] = $request->has('author_realname') - ? $request->input('author_realname') : $current_post->author_realname; $input['form_id'] = $request->has('form_id') ? $request->input('form_id') : $current_post->form_id; $input['parent_id'] = $request->has('parent_id') From 2d7d38b77752a0d5b26fa527834c72716aa73067 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 20:40:30 +0200 Subject: [PATCH 3/4] The backend changes focused on enabling the API to calculate and sort by the most relevant date for a report (either a custom user-provided date or the system-recorded date) the custom-user-provided date is sometimes used by clients fo flag a possible event in the future with this sort we can now list the possible future events. --- .../Post/EloquentPostRepository.php | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php b/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php index 1a776ab1e..c340aa139 100644 --- a/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php +++ b/src/Ushahidi/Modules/V5/Repository/Post/EloquentPostRepository.php @@ -108,33 +108,6 @@ private function setSearchCondition(PostSearchFields $search_fields, $query, boo } } - if (!$search_fields->isAdmin()) { - $role = $search_fields->role(); - $query->where(function ($query) use ($role) { - $query->whereNull('posts.form_id') - ->orWhere(function ($formQuery) use ($role) { - $formQuery->whereNotNull('posts.form_id') - ->where(function ($roleQuery) use ($role) { - $roleQuery->whereNotExists(function ($subquery) { - $subquery->select(DB::raw(1)) - ->from('form_roles') - ->whereColumn('form_roles.form_id', 'posts.form_id'); - }); - - if ($role) { - $roleQuery->orWhereExists(function ($subquery) use ($role) { - $subquery->select(DB::raw(1)) - ->from('form_roles') - ->join('roles', 'roles.id', '=', 'form_roles.role_id') - ->whereColumn('form_roles.form_id', 'posts.form_id') - ->where('roles.name', '=', $role); - }); - } - }); - }); - }); - } - if (count($search_fields->user())) { $query->whereIn('posts.user_id', $search_fields->user()); } elseif ($search_fields->userNone()) { From 64fc27fcbf8f601ba5d43805140843a91228b5db Mon Sep 17 00:00:00 2001 From: root Date: Thu, 19 Mar 2026 20:42:39 +0200 Subject: [PATCH 4/4] This feature was implemented to improve administrative flexibility and data management efficiency within the Ushahidi platform. In complex deployments, reports are frequently submitted to the wrong campaign or require reclassification as project structures evolve. Previously, correcting these misplacements required tedious manual data entry. By providing a native Move capability, users can quickly rectify filing errors, consolidate data across campaigns, and ensure that reports are always associated with the correct set of fields and workflows without losing the original post's history or metadata. --- .../V5/Http/Controllers/PostController.php | 156 ++++++++++++++++++ src/Ushahidi/Modules/V5/routes/api.php | 2 + 2 files changed, 158 insertions(+) diff --git a/src/Ushahidi/Modules/V5/Http/Controllers/PostController.php b/src/Ushahidi/Modules/V5/Http/Controllers/PostController.php index a00de20a2..8cdb2c43a 100644 --- a/src/Ushahidi/Modules/V5/Http/Controllers/PostController.php +++ b/src/Ushahidi/Modules/V5/Http/Controllers/PostController.php @@ -67,6 +67,162 @@ protected function ignoreInput() return ['author_email', 'slug', 'user_id', 'author_realname', 'created', 'updated']; } + public function unread(Request $request) + { + $refreshed_at = $request->query('refreshed_at'); + $now = time(); + + if (!$refreshed_at || !is_numeric($refreshed_at) || (int)$refreshed_at >= $now) { + $refreshed_at = $now; + } + + $results = DB::select("SELECT COUNT(*) AS count from posts WHERE created > ?", [$refreshed_at]); + $count = $results[0]->count ?? 0; + + return response()->json(['count' => $count]); + } + + public function move(Request $request) + { + $authorizer = service('authorizer.form'); + $user = $authorizer->getUser(); + $role = $user->role; + + if (!in_array($role, ['admin', 'user'], true)) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $post_id = (int) $request->query('post_id'); + $form_id = (int) $request->query('form_id'); + + if (!$post_id || !$form_id) { + return response()->json(['error' => 'Missing post_id or form_id'], 422); + } + + DB::beginTransaction(); + try { + // Step 1: Fetch current post data + $post_rows = DB::select( + "SELECT form_id as old_form_id, content FROM ushahidi.posts WHERE id = ?", + [$post_id] + ); + + if (empty($post_rows)) { + DB::rollback(); + return response()->json(['error' => 'Post not found'], 404); + } + + $old_form_id = $post_rows[0]->old_form_id; + $content = $post_rows[0]->content ?? ''; + + // Fetch old and new form names + $old_form_rows = DB::select( + "SELECT name FROM ushahidi.forms WHERE id = ?", + [$old_form_id] + ); + $form_name = $old_form_rows[0]->name ?? $old_form_id; + + $new_form_rows = DB::select( + "SELECT name FROM ushahidi.forms WHERE id = ?", + [$form_id] + ); + $new_form_name = $new_form_rows[0]->name ?? $form_id; + + // Append migration note to content + $content .= "\n\n--- Survey moved from [{$form_name}] to [{$new_form_name}] ---\n\n"; + + // Step 2: Fetch old form attributes + $old_attributes = DB::select( + "SELECT A.id, A.label, A.input, A.type, A.options + FROM ushahidi.form_attributes A + JOIN ushahidi.form_stages B ON (A.form_stage_id = B.id) + WHERE B.form_id = ?", + [$old_form_id] + ); + + // Fetch new form attributes + $new_attributes = DB::select( + "SELECT A.id, A.label, A.input, A.type, A.options + FROM ushahidi.form_attributes A + JOIN ushahidi.form_stages B ON (A.form_stage_id = B.id) + WHERE B.form_id = ?", + [$form_id] + ); + + // Step 3: Process each old attribute (skip title and description types) + foreach ($old_attributes as $old_attr) { + $id = $old_attr->id; + $label = $old_attr->label; + $input = $old_attr->input; + $type = $old_attr->type; + $options = $old_attr->options; + + if (in_array($type, ['title', 'description', 'tags'], true)) { + continue; + } + + // Look for a matching attribute in the new form + $matched_new = null; + foreach ($new_attributes as $new_attr) { + if ( + $new_attr->label === $label && + $new_attr->input === $input && + $new_attr->type === $type && + $new_attr->options === $options + ) { + $matched_new = $new_attr; + break; + } + } + + if ($matched_new !== null) { + // Matching attribute found — reassign the post value to the new attribute id + $new_id = $matched_new->id; + DB::statement( + "UPDATE ushahidi.post_{$type} + SET form_attribute_id = ? + WHERE post_id = ? AND form_attribute_id = ?", + [$new_id, $post_id, $id] + ); + } else { + // No match — retrieve current value, delete the row, and log it in content + $value_rows = DB::select( + "SELECT value FROM ushahidi.post_{$type} + WHERE post_id = ? AND form_attribute_id = ?", + [$post_id, $id] + ); + $value = $value_rows[0]->value ?? ''; + + DB::statement( + "DELETE FROM ushahidi.post_{$type} + WHERE post_id = ? AND form_attribute_id = ?", + [$post_id, $id] + ); + + $content .= "{$label} -> {$value}\n"; + } + } + + // Step 4: Update the post with the new form_id and updated content + DB::statement( + "UPDATE ushahidi.posts SET form_id = ?, content = ? WHERE id = ?", + [$form_id, $content, $post_id] + ); + + DB::commit(); + + return response()->json([ + 'status' => 'completed', + 'post_id' => $post_id, + 'form_id' => $form_id, + ], 200); + + } catch (\Exception $e) { + DB::rollback(); + return response()->json(['error' => $e->getMessage()], 500); + } + } + /** * Display the specified resource. * diff --git a/src/Ushahidi/Modules/V5/routes/api.php b/src/Ushahidi/Modules/V5/routes/api.php index 71d5689c7..4d7f39cb9 100644 --- a/src/Ushahidi/Modules/V5/routes/api.php +++ b/src/Ushahidi/Modules/V5/routes/api.php @@ -105,6 +105,8 @@ function () use ($router) { $router->get('/', 'PostController@index'); $router->get('/stats', 'PostController@stats'); + $router->get('/unread', 'PostController@unread'); + $router->get('/move', 'PostController@move'); $router->get('/geojson', 'PostController@indexGeoJson'); $router->get('/geojson/{zoom}/{x}/{y}', 'PostController@indexGeoJsonWithZoom'); $router->get('/{id}', 'PostController@show');