Skip to content

Commit 9206dfe

Browse files
committed
feat: Implement inspection mode features to enhance data privacy, including read-only access and UI adjustments for sensitive financial information across various components.
1 parent f2c26ce commit 9206dfe

60 files changed

Lines changed: 2596 additions & 1248 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Blog\Services;
6+
7+
use App\Models\User;
8+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
9+
use Illuminate\Database\Eloquent\Collection;
10+
use Illuminate\Support\Str;
11+
use Illuminate\Support\Facades\Storage;
12+
use Modules\Blog\Models\Post;
13+
use Modules\Blog\Models\BlogCategory;
14+
use Modules\Blog\Models\Comment;
15+
use Modules\Blog\Models\PostLike;
16+
use Modules\Blog\Models\SavedPost;
17+
use Modules\Core\Services\SettingService;
18+
19+
class BlogService
20+
{
21+
public function __construct(
22+
protected SettingService $settingService
23+
) {}
24+
25+
/**
26+
* Get published posts with optional search and category filter.
27+
*/
28+
public function getPublishedPosts(array $filters = []): LengthAwarePaginator
29+
{
30+
$query = Post::where('status', 'published')
31+
->with('author', 'category')
32+
->orderBy('created_at', 'desc');
33+
34+
if (!empty($filters['q'])) {
35+
$q = $filters['q'];
36+
$query->where(function ($qry) use ($q) {
37+
$qry->where('title', 'like', "%{$q}%")
38+
->orWhere('content', 'like', "%{$q}%");
39+
});
40+
}
41+
42+
if (!empty($filters['category_id'])) {
43+
$query->where('category_id', $filters['category_id']);
44+
}
45+
46+
return $query->paginate($filters['per_page'] ?? 12);
47+
}
48+
49+
/**
50+
* Get a published post by slug with relations.
51+
*/
52+
public function getPostBySlug(string $slug): ?Post
53+
{
54+
return Post::where('slug', $slug)
55+
->where('status', 'published')
56+
->with('author', 'category', 'approvedComments.user')
57+
->first();
58+
}
59+
60+
/**
61+
* Generate unique slug from title.
62+
*/
63+
public function generateSlug(string $title, ?int $excludeId = null): string
64+
{
65+
$slug = Str::slug($title);
66+
$query = Post::where('slug', 'like', $slug . '%');
67+
if ($excludeId !== null) {
68+
$query->where('id', '!=', $excludeId);
69+
}
70+
$count = $query->count();
71+
return $count > 0 ? $slug . '-' . ($count + 1) : $slug;
72+
}
73+
74+
/**
75+
* Create a new post.
76+
*/
77+
public function createPost(array $data, int $authorId): Post
78+
{
79+
$slug = $data['slug'] ?? $this->generateSlug($data['title']);
80+
81+
$featuredImage = null;
82+
if (!empty($data['featured_image'])) {
83+
$featuredImage = $this->storeUploadedFile($data['featured_image'], 'blog');
84+
}
85+
86+
$ogImage = null;
87+
if (!empty($data['og_image'])) {
88+
$ogImage = $this->storeUploadedFile($data['og_image'], 'blog/seo');
89+
}
90+
91+
return Post::create([
92+
'author_id' => $authorId,
93+
'category_id' => $data['category_id'],
94+
'title' => $data['title'],
95+
'slug' => $slug,
96+
'content' => $data['content'],
97+
'status' => $data['status'] ?? 'draft',
98+
'is_premium' => (bool) ($data['is_premium'] ?? false),
99+
'featured_image' => $featuredImage,
100+
'meta_description' => $data['meta_description'] ?? null,
101+
'og_image' => $ogImage,
102+
]);
103+
}
104+
105+
/**
106+
* Update an existing post.
107+
*/
108+
public function updatePost(Post $post, array $data): Post
109+
{
110+
if (isset($data['title']) && $data['title'] !== $post->title) {
111+
$data['slug'] = $data['slug'] ?? $this->generateSlug($data['title'], $post->id);
112+
}
113+
114+
if (!empty($data['featured_image'])) {
115+
$data['featured_image'] = $this->storeUploadedFile($data['featured_image'], 'blog');
116+
}
117+
118+
if (!empty($data['og_image'])) {
119+
$data['og_image'] = $this->storeUploadedFile($data['og_image'], 'blog/seo');
120+
}
121+
122+
$fillable = ['title', 'slug', 'category_id', 'content', 'status', 'is_premium', 'meta_description', 'featured_image', 'og_image'];
123+
$updates = array_intersect_key($data, array_flip($fillable));
124+
if (isset($updates['is_premium'])) {
125+
$updates['is_premium'] = (bool) $updates['is_premium'];
126+
}
127+
128+
$post->update($updates);
129+
return $post;
130+
}
131+
132+
/**
133+
* Store a comment; respects auto_approve_comments setting.
134+
*/
135+
public function storeComment(Post $post, array $data, int $userId): Comment
136+
{
137+
$autoApprove = (bool) $this->settingService->get('blog.auto_approve_comments', false);
138+
139+
return Comment::create([
140+
'post_id' => $post->id,
141+
'user_id' => $userId,
142+
'content' => $data['content'],
143+
'is_approved' => $autoApprove,
144+
]);
145+
}
146+
147+
/**
148+
* Toggle like for a post. Returns ['liked' => bool, 'count' => int].
149+
*/
150+
public function toggleLike(Post $post, User $user): array
151+
{
152+
$like = PostLike::where('post_id', $post->id)->where('user_id', $user->id)->first();
153+
154+
if ($like) {
155+
$like->delete();
156+
return ['liked' => false, 'count' => $post->likes()->count()];
157+
}
158+
159+
PostLike::create(['post_id' => $post->id, 'user_id' => $user->id]);
160+
return ['liked' => true, 'count' => $post->likes()->count()];
161+
}
162+
163+
/**
164+
* Toggle save for a post. Returns ['saved' => bool].
165+
*/
166+
public function toggleSave(Post $post, User $user): array
167+
{
168+
$saved = SavedPost::where('post_id', $post->id)->where('user_id', $user->id)->first();
169+
170+
if ($saved) {
171+
$saved->delete();
172+
return ['saved' => false];
173+
}
174+
175+
SavedPost::create(['post_id' => $post->id, 'user_id' => $user->id]);
176+
return ['saved' => true];
177+
}
178+
179+
/**
180+
* Create a category.
181+
*/
182+
public function storeCategory(array $data): BlogCategory
183+
{
184+
return BlogCategory::create([
185+
'name' => $data['name'],
186+
'slug' => Str::slug($data['name']),
187+
'icon' => $data['icon'] ?? null,
188+
]);
189+
}
190+
191+
/**
192+
* Update a category.
193+
*/
194+
public function updateCategory(BlogCategory $category, array $data): BlogCategory
195+
{
196+
$category->update([
197+
'name' => $data['name'],
198+
'slug' => Str::slug($data['name']),
199+
'icon' => $data['icon'] ?? $category->icon,
200+
]);
201+
return $category;
202+
}
203+
204+
protected function storeUploadedFile($file, string $path): string
205+
{
206+
$stored = $file->store($path, 'public');
207+
return 'storage/' . $stored;
208+
}
209+
}

Modules/Blog/routes/web.php

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
<?php
22

33
use Illuminate\Support\Facades\Route;
4-
use Modules\Blog\Http\Controllers\BlogController;
54

6-
Route::middleware(['auth', 'verified'])->group(function () {
7-
Route::get('blog', [BlogController::class, 'index'])->name('blog.index');
8-
Route::get('blog/{slug}', [BlogController::class, 'show'])->name('blog.show');
9-
Route::post('blog/comment/{id}', [BlogController::class, 'storeComment']);
10-
Route::post('blog/like/{id}', [BlogController::class, 'toggleLike']);
11-
Route::post('blog/save/{id}', [BlogController::class, 'toggleSave']);
12-
});
5+
// Public blog routes disabled: blog is served under PanelUser at /user/blog (paneluser.blog.*)

Modules/Core/app/Http/Controllers/AccountController.php

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
use Modules\Core\Http\Requests\StoreAccountRequest;
88
use Modules\Core\Http\Requests\UpdateAccountRequest;
99
use Modules\Core\Models\Account;
10-
10+
use Modules\Core\Services\InspectionGuard;
1111
use Modules\Core\Services\SubscriptionLimitService;
1212

1313
class AccountController extends Controller
@@ -112,10 +112,14 @@ public function update(UpdateAccountRequest $request, Account $account)
112112
{
113113
$this->authorize('update', $account);
114114

115+
$balance = InspectionGuard::shouldHideFinancialData()
116+
? $account->balance
117+
: $request->balance;
118+
115119
$account->update([
116120
'name' => $request->name,
117121
'type' => $request->type,
118-
'balance' => $request->balance,
122+
'balance' => $balance,
119123
]);
120124

121125
return redirect()->route('core.accounts.index')

Modules/Core/app/Http/Controllers/CoreController.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Modules\Core\Models\Goal;
1717
use Modules\Core\Models\Transaction;
1818
use Modules\Core\Services\FinancialHealthService;
19+
use Modules\Core\Services\InspectionGuard;
1920
use Modules\Core\Services\SubscriptionLimitService;
2021

2122
class CoreController extends Controller
@@ -116,6 +117,10 @@ public function dashboard()
116117
// Prepare category spending data for chart
117118
$categoryData = $this->prepareCategoryData($user);
118119

120+
// Mask chart data when inspection active without financial permission
121+
$cashFlowData = InspectionGuard::maskChartData($cashFlowData);
122+
$categoryData = InspectionGuard::maskChartData($categoryData);
123+
119124
// Recent transactions for Pro dashboard table
120125
$recentTransactions = Transaction::where('user_id', $user->id)
121126
->with('category')

Modules/Core/app/Http/Middleware/BlockSensitiveInspectionActions.php

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,61 @@
44

55
use Closure;
66
use Illuminate\Http\Request;
7+
use Modules\Core\Services\InspectionGuard;
78
use Symfony\Component\HttpFoundation\Response;
89

10+
/**
11+
* Bloqueia alterações de dados durante inspeção remota.
12+
* Modo inspeção = SOMENTE LEITURA. O agente pode visualizar, mas não criar, editar ou excluir.
13+
*/
914
class BlockSensitiveInspectionActions
1015
{
1116
/**
12-
* Handle an incoming request.
17+
* Rotas que PODEM receber POST/PUT/PATCH durante inspeção (exceções necessárias).
1318
*/
19+
private const INSPECTION_ALLOWED_MUTATIONS = [
20+
'user.inspection.accept',
21+
'user.inspection.reject',
22+
'support.inspection.stop',
23+
];
24+
25+
/**
26+
* Rotas de exportação: bloqueadas quando usuário negou exibir dados financeiros.
27+
*/
28+
private const FINANCIAL_EXPORT_ROUTES = [
29+
'core.reports.export.cashflow.pdf',
30+
'core.reports.export.cashflow.csv',
31+
'core.reports.export.categories.csv',
32+
];
33+
1434
public function handle(Request $request, Closure $next): Response
1535
{
16-
if (session()->has('impersonate_inspection_id')) {
17-
// Define sensitive route names or patterns
18-
$sensitiveRoutes = [
19-
'user.security.password',
20-
'user.profile.update', // Optional: maybe agent can help fix profile? But password is critical.
21-
'user.profile.delete', // If it exists
22-
'admin.users.delete',
23-
'admin.settings.update',
24-
];
25-
26-
if ($request->routeIs($sensitiveRoutes) || $request->isMethod('DELETE')) {
27-
if ($request->ajax() || $request->wantsJson()) {
28-
return response()->json([
29-
'message' => 'Ação bloqueada: Agentes em modo de inspeção não podem realizar esta alteração sensível.',
30-
], 403);
31-
}
32-
33-
return back()->with('error', 'Ação bloqueada durante o modo de inspeção por motivos de segurança.');
36+
if (! session()->has('impersonate_inspection_id')) {
37+
return $next($request);
38+
}
39+
40+
// Bloquear TODAS as mutações (POST, PUT, PATCH, DELETE) exceto as permitidas
41+
$isMutation = in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true);
42+
43+
if ($isMutation && ! $request->routeIs(self::INSPECTION_ALLOWED_MUTATIONS)) {
44+
if ($request->ajax() || $request->wantsJson()) {
45+
return response()->json([
46+
'message' => 'Modo inspeção: somente leitura. Não é possível criar, editar ou excluir dados.',
47+
], 403);
48+
}
49+
50+
return back()->with('error', 'Modo inspeção ativo: somente visualização. Alterações não são permitidas.');
51+
}
52+
53+
// Exportações: bloquear se usuário negou dados financeiros
54+
if (InspectionGuard::shouldHideFinancialData() && $request->routeIs(self::FINANCIAL_EXPORT_ROUTES)) {
55+
if ($request->ajax() || $request->wantsJson()) {
56+
return response()->json([
57+
'message' => 'Exportação bloqueada: dados financeiros não autorizados para esta sessão.',
58+
], 403);
3459
}
60+
61+
return back()->with('error', 'Exportação bloqueada durante a inspeção por privacidade.');
3562
}
3663

3764
return $next($request);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Modules\Core\Http\Middleware;
6+
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Cache;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
/**
13+
* Durante inspeção ativa: armazena a URL atual que o agente está visualizando
14+
* para que o cliente possa acompanhar em tempo real via polling.
15+
*/
16+
class StoreInspectionViewUrl
17+
{
18+
private const CACHE_PREFIX = 'inspection_view_';
19+
private const CACHE_TTL_SECONDS = 90;
20+
21+
public function handle(Request $request, Closure $next): Response
22+
{
23+
$response = $next($request);
24+
25+
$inspectionId = session('impersonate_inspection_id');
26+
if ($inspectionId && $request->isMethod('GET') && ! $request->ajax()) {
27+
Cache::put(self::CACHE_PREFIX . $inspectionId, $request->fullUrl(), self::CACHE_TTL_SECONDS);
28+
}
29+
30+
return $response;
31+
}
32+
}

Modules/Core/app/Models/TicketMessage.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ class TicketMessage extends Model
1515
'user_id',
1616
'message',
1717
'is_admin_reply',
18+
'is_system',
19+
];
20+
21+
protected $casts = [
22+
'is_system' => 'boolean',
1823
];
1924

2025
public function ticket()

0 commit comments

Comments
 (0)