diff --git a/api/app/Console/Commands/SendSystemNotification.php b/api/app/Console/Commands/SendSystemNotification.php new file mode 100644 index 0000000..3a08318 --- /dev/null +++ b/api/app/Console/Commands/SendSystemNotification.php @@ -0,0 +1,40 @@ +option('title-key'); + $messageKey = $this->option('message-key'); + + if (empty($titleKey) || empty($messageKey)) { + $this->error('Both --title-key and --message-key are required.'); + + return self::FAILURE; + } + + $notification = new SystemNotification($titleKey, $messageKey); + + $this->info('Sending system notification to all users...'); + + $notificationService->notifyAllUsers($notification); + + $this->info('System notification dispatched successfully.'); + + return self::SUCCESS; + } +} diff --git a/api/app/Http/Controllers/API/Notification/NotificationController.php b/api/app/Http/Controllers/API/Notification/NotificationController.php new file mode 100644 index 0000000..bfb6bd8 --- /dev/null +++ b/api/app/Http/Controllers/API/Notification/NotificationController.php @@ -0,0 +1,145 @@ + []]], + tags: ['Notifications'], + parameters: [ + new OA\Parameter( + name: 'page', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer'), + description: 'Page number' + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Notifications retrieved successfully', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(ref: '#/components/schemas/NotificationResource') + ), + new OA\Property( + property: 'meta', + type: 'object', + properties: [ + new OA\Property(property: 'unreadCount', type: 'integer'), + ] + ), + ] + ) + ), + ] + )] + public function index(Request $request) + { + $user = $request->user(); + $notifications = $this->notificationService->listForUser($user); + $unreadCount = $this->notificationService->unreadCount($user); + + return NotificationResource::collection($notifications) + ->additional(['meta' => ['unreadCount' => $unreadCount]]); + } + + #[OA\Post( + operationId: 'markNotificationAsRead', + path: '/api/notifications/{id}/read', + summary: 'Mark a notification as read', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + parameters: [ + new OA\Parameter( + name: 'id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid'), + description: 'Notification ID' + ), + ], + responses: [ + new OA\Response(response: 204, description: 'Notification marked as read'), + ] + )] + public function markAsRead(Request $request, string $id) + { + $this->notificationService->markAsRead($request->user(), $id); + + return response()->noContent(); + } + + #[OA\Post( + operationId: 'markAllNotificationsAsRead', + path: '/api/notifications/read-all', + summary: 'Mark all notifications as read', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + responses: [ + new OA\Response(response: 204, description: 'All notifications marked as read'), + ] + )] + public function markAllAsRead(Request $request) + { + $this->notificationService->markAllAsRead($request->user()); + + return response()->noContent(); + } + + #[OA\Delete( + operationId: 'deleteNotification', + path: '/api/notifications/{id}', + summary: 'Delete a notification', + security: [['bearerAuth' => []]], + tags: ['Notifications'], + parameters: [ + new OA\Parameter( + name: 'id', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid'), + description: 'Notification ID' + ), + ], + responses: [ + new OA\Response(response: 204, description: 'Notification deleted'), + ] + )] + public function destroy(Request $request, string $id) + { + $this->notificationService->delete($request->user(), $id); + + return response()->noContent(); + } +} diff --git a/api/app/Http/Controllers/API/Payment/WooviWebhookController.php b/api/app/Http/Controllers/API/Payment/WooviWebhookController.php index 668b981..12b2915 100644 --- a/api/app/Http/Controllers/API/Payment/WooviWebhookController.php +++ b/api/app/Http/Controllers/API/Payment/WooviWebhookController.php @@ -7,6 +7,7 @@ use App\Events\DonationPaid; use App\Http\Controllers\Controller; use App\Models\Donation; +use App\Notifications\DonationReceived; use Illuminate\Http\Request; use OpenPix\PhpSdk\Client; @@ -80,6 +81,8 @@ public function handleChargePaidWebhook(Request $request) event(new DonationPaid($donation->external_reference)); + $donation->campaign->user->notify(new DonationReceived($donation->load('campaign'))); + return response()->json(['message' => 'Success.']); } diff --git a/api/app/Http/Controllers/Controller.php b/api/app/Http/Controllers/Controller.php index 11d2e84..3e7e31e 100644 --- a/api/app/Http/Controllers/Controller.php +++ b/api/app/Http/Controllers/Controller.php @@ -29,6 +29,7 @@ #[OA\Tag(name: 'Withdrawals', description: 'Withdrawal management endpoints')] #[OA\Tag(name: 'Reports', description: 'Report management endpoints')] #[OA\Tag(name: 'Leaderboard', description: 'Leaderboard endpoints')] +#[OA\Tag(name: 'Notifications', description: 'Notification management endpoints')] abstract class Controller { // diff --git a/api/app/Http/Middleware/SetLocale.php b/api/app/Http/Middleware/SetLocale.php new file mode 100644 index 0000000..618c21f --- /dev/null +++ b/api/app/Http/Middleware/SetLocale.php @@ -0,0 +1,22 @@ +getPreferredLanguage(['en', 'es', 'pt']); + + App::setLocale($locale ?? 'pt'); + + return $next($request); + } +} diff --git a/api/app/Http/Resources/Notification/NotificationResource.php b/api/app/Http/Resources/Notification/NotificationResource.php new file mode 100644 index 0000000..4468f50 --- /dev/null +++ b/api/app/Http/Resources/Notification/NotificationResource.php @@ -0,0 +1,36 @@ +data; + + return [ + 'id' => $this->id, + 'type' => $data['type'], + 'title' => __($data['titleKey'], $data['params'] ?? []), + 'message' => __($data['messageKey'], $data['params'] ?? []), + 'timestamp' => $this->created_at->toIso8601String(), + 'isRead' => $this->read_at !== null, + ]; + } +} diff --git a/api/app/Notifications/CampaignApproved.php b/api/app/Notifications/CampaignApproved.php new file mode 100644 index 0000000..4acc85e --- /dev/null +++ b/api/app/Notifications/CampaignApproved.php @@ -0,0 +1,37 @@ + 'campaign', + 'titleKey' => 'notifications.campaign.title', + 'messageKey' => 'notifications.campaign.message', + 'params' => [ + 'campaign' => $this->campaign->title, + 'campaignSlug' => $this->campaign->slug, + ], + ]; + } +} diff --git a/api/app/Notifications/DonationReceived.php b/api/app/Notifications/DonationReceived.php new file mode 100644 index 0000000..fe1a98d --- /dev/null +++ b/api/app/Notifications/DonationReceived.php @@ -0,0 +1,38 @@ + 'donation', + 'titleKey' => 'notifications.donation.title', + 'messageKey' => 'notifications.donation.message', + 'params' => [ + 'amount' => 'R$ '.number_format($this->donation->amount_cents / 100, 2, ',', '.'), + 'campaign' => $this->donation->campaign->title, + 'campaignSlug' => $this->donation->campaign->slug, + ], + ]; + } +} diff --git a/api/app/Notifications/SystemNotification.php b/api/app/Notifications/SystemNotification.php new file mode 100644 index 0000000..fea2fba --- /dev/null +++ b/api/app/Notifications/SystemNotification.php @@ -0,0 +1,35 @@ + 'system', + 'titleKey' => $this->titleKey, + 'messageKey' => $this->messageKey, + 'params' => $this->params, + ]; + } +} diff --git a/api/app/Notifications/WithdrawalProcessed.php b/api/app/Notifications/WithdrawalProcessed.php new file mode 100644 index 0000000..289a5da --- /dev/null +++ b/api/app/Notifications/WithdrawalProcessed.php @@ -0,0 +1,37 @@ + 'withdrawal', + 'titleKey' => 'notifications.withdrawal.title', + 'messageKey' => 'notifications.withdrawal.message', + 'params' => [ + 'amount' => 'R$ '.number_format($this->withdrawal->amountCents / 100, 2, ',', '.'), + 'campaign' => $this->withdrawal->campaign->title, + ], + ]; + } +} diff --git a/api/app/Services/Campaign/CampaignService.php b/api/app/Services/Campaign/CampaignService.php index 159a892..5e47b33 100644 --- a/api/app/Services/Campaign/CampaignService.php +++ b/api/app/Services/Campaign/CampaignService.php @@ -6,6 +6,7 @@ use App\Models\Campaign; use App\Models\User; +use App\Notifications\CampaignApproved; use App\Services\Donation\DonationService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Str; @@ -106,9 +107,14 @@ public function create(array $data, User $user): Campaign public function update(Campaign $campaign, array $data): Campaign { + $previousStatus = $campaign->status; $data = $this->mapPersistentEntity($data); $campaign->update($data); + if ($campaign->status === Campaign::STATUS_OPEN && $previousStatus !== Campaign::STATUS_OPEN) { + $campaign->user->notify(new CampaignApproved($campaign)); + } + return $campaign; } diff --git a/api/app/Services/Notification/NotificationService.php b/api/app/Services/Notification/NotificationService.php new file mode 100644 index 0000000..6959ff4 --- /dev/null +++ b/api/app/Services/Notification/NotificationService.php @@ -0,0 +1,45 @@ +notifications()->latest()->paginate(15); + } + + public function unreadCount(User $user): int + { + return $user->unreadNotifications()->count(); + } + + public function markAsRead(User $user, string $notificationId): void + { + $user->notifications()->findOrFail($notificationId)->markAsRead(); + } + + public function markAllAsRead(User $user): void + { + $user->unreadNotifications->markAsRead(); + } + + public function delete(User $user, string $notificationId): void + { + $user->notifications()->findOrFail($notificationId)->delete(); + } + + public function notifyAllUsers(Notification $notification): void + { + User::chunk(100, function ($users) use ($notification) { + NotificationFacade::send($users, $notification); + }); + } +} diff --git a/api/app/Services/Withdrawal/WithdrawalProcessor.php b/api/app/Services/Withdrawal/WithdrawalProcessor.php index d3aea39..fe71f7c 100644 --- a/api/app/Services/Withdrawal/WithdrawalProcessor.php +++ b/api/app/Services/Withdrawal/WithdrawalProcessor.php @@ -5,6 +5,7 @@ namespace App\Services\Withdrawal; use App\Models\Withdrawal; +use App\Notifications\WithdrawalProcessed; use Illuminate\Support\Str; use OpenPix\PhpSdk\Client; use OpenPix\PhpSdk\Request; @@ -51,5 +52,7 @@ public function process(Withdrawal $withdrawal) $withdrawal->status = 'paid'; $withdrawal->campaign()->decrement('available_balance_cents', $withdrawal->amountCents); $withdrawal->save(); + + $withdrawal->campaign->user->notify(new WithdrawalProcessed($withdrawal->load('campaign'))); } } diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 930c5c3..9de13d6 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -14,6 +14,9 @@ ) ->withMiddleware(function (Middleware $middleware) { $middleware->statefulApi(); + $middleware->api(append: [ + \App\Http\Middleware\SetLocale::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/api/database/migrations/2026_03_29_212125_create_notifications_table.php b/api/database/migrations/2026_03_29_212125_create_notifications_table.php new file mode 100644 index 0000000..d738032 --- /dev/null +++ b/api/database/migrations/2026_03_29_212125_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/api/lang/en/notifications.php b/api/lang/en/notifications.php new file mode 100644 index 0000000..5b3dc9e --- /dev/null +++ b/api/lang/en/notifications.php @@ -0,0 +1,22 @@ + [ + 'title' => 'New donation received!', + 'message' => 'You received a donation of :amount on the campaign ":campaign"', + ], + 'campaign' => [ + 'title' => 'Campaign approved', + 'message' => 'Your campaign ":campaign" has been approved and is now active!', + ], + 'withdrawal' => [ + 'title' => 'Withdrawal processed', + 'message' => 'Your withdrawal of :amount has been processed successfully.', + ], + 'system' => [ + 'policy_update' => [ + 'title' => 'Policy update', + 'message' => 'We have updated our privacy policy. Click to learn more.', + ], + ], +]; diff --git a/api/lang/es/notifications.php b/api/lang/es/notifications.php new file mode 100644 index 0000000..a79f0b0 --- /dev/null +++ b/api/lang/es/notifications.php @@ -0,0 +1,22 @@ + [ + 'title' => '¡Nueva donación recibida!', + 'message' => 'Recibiste una donación de :amount en la campaña ":campaign"', + ], + 'campaign' => [ + 'title' => 'Campaña aprobada', + 'message' => '¡Tu campaña ":campaign" fue aprobada y ya está activa!', + ], + 'withdrawal' => [ + 'title' => 'Retiro procesado', + 'message' => 'Tu retiro de :amount fue procesado con éxito.', + ], + 'system' => [ + 'policy_update' => [ + 'title' => 'Actualización de política', + 'message' => 'Actualizamos nuestra política de privacidad. Haz clic para saber más.', + ], + ], +]; diff --git a/api/lang/pt/notifications.php b/api/lang/pt/notifications.php new file mode 100644 index 0000000..526b40e --- /dev/null +++ b/api/lang/pt/notifications.php @@ -0,0 +1,22 @@ + [ + 'title' => 'Nova doação recebida!', + 'message' => 'Você recebeu uma doação de :amount na campanha ":campaign"', + ], + 'campaign' => [ + 'title' => 'Campanha aprovada', + 'message' => 'Sua campanha ":campaign" foi aprovada e já está ativa!', + ], + 'withdrawal' => [ + 'title' => 'Saque processado', + 'message' => 'Seu saque de :amount foi processado com sucesso.', + ], + 'system' => [ + 'policy_update' => [ + 'title' => 'Atualização de política', + 'message' => 'Atualizamos nossa política de privacidade. Clique para saber mais.', + ], + ], +]; diff --git a/api/public/api-docs.json b/api/public/api-docs.json index a0ff891..8794560 100644 --- a/api/public/api-docs.json +++ b/api/public/api-docs.json @@ -99,18 +99,18 @@ } } }, - "/api/campaigns/{identifier}": { + "/api/campaigns/{slug}": { "get": { "tags": [ "Campaigns" ], - "summary": "Get campaign by slug or ID", + "summary": "Get campaign by slug", "operationId": "getCampaign", "parameters": [ { - "name": "identifier", + "name": "slug", "in": "path", - "description": "Campaign slug or ID", + "description": "Campaign slug", "required": true, "schema": { "type": "string" @@ -129,9 +129,7 @@ } } } - } - }, - "/api/campaigns/{slug}": { + }, "put": { "tags": [ "Campaigns" @@ -721,7 +719,7 @@ } } }, - "/api/campaigns/{identifier}/donations": { + "/api/campaigns/{campaign}/donations": { "get": { "tags": [ "Donations" @@ -730,9 +728,9 @@ "operationId": "listCampaignDonations", "parameters": [ { - "name": "identifier", + "name": "campaign", "in": "path", - "description": "Campaign ID or slug", + "description": "Campaign slug", "required": true, "schema": { "type": "string" @@ -825,27 +823,27 @@ } } }, - "/api/campaigns/{identifier}/donations/thankable": { + "/api/notifications": { "get": { "tags": [ - "Donations" + "Notifications" ], - "summary": "List thankable donations for a campaign", - "operationId": "listThankableDonations", + "summary": "List authenticated user notifications", + "operationId": "listNotifications", "parameters": [ { - "name": "identifier", - "in": "path", - "description": "Campaign ID or slug", - "required": true, + "name": "page", + "in": "query", + "description": "Page number", + "required": false, "schema": { - "type": "string" + "type": "integer" } } ], "responses": { "200": { - "description": "Thankable donations retrieved successfully", + "description": "Notifications retrieved successfully", "content": { "application/json": { "schema": { @@ -853,8 +851,16 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/DonationResource" + "$ref": "#/components/schemas/NotificationResource" } + }, + "meta": { + "properties": { + "unreadCount": { + "type": "integer" + } + }, + "type": "object" } }, "type": "object" @@ -865,113 +871,88 @@ }, "security": [ { - "sanctum": [] + "bearerAuth": [] } ] } }, - "/api/donations/{id}/thank": { + "/api/notifications/{id}/read": { "post": { "tags": [ - "Donations" + "Notifications" ], - "summary": "Send thank you message to a donor", - "operationId": "thankDonor", + "summary": "Mark a notification as read", + "operationId": "markNotificationAsRead", "parameters": [ { "name": "id", "in": "path", - "description": "Donation ID", + "description": "Notification ID", "required": true, "schema": { - "type": "integer" + "type": "string", + "format": "uuid" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ThankDonorRequest" - } - } + "responses": { + "204": { + "description": "Notification marked as read" } }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications/read-all": { + "post": { + "tags": [ + "Notifications" + ], + "summary": "Mark all notifications as read", + "operationId": "markAllNotificationsAsRead", "responses": { - "200": { - "description": "Thank you message sent successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DonationResource" - } - } - } - }, - "403": { - "description": "Forbidden" - }, - "422": { - "description": "Validation error" + "204": { + "description": "All notifications marked as read" } }, "security": [ { - "sanctum": [] + "bearerAuth": [] } ] } }, - "/api/campaigns/{identifier}/donations/thank": { - "post": { + "/api/notifications/{id}": { + "delete": { "tags": [ - "Donations" + "Notifications" ], - "summary": "Send thank you message to multiple donors", - "operationId": "bulkThankDonors", + "summary": "Delete a notification", + "operationId": "deleteNotification", "parameters": [ { - "name": "identifier", + "name": "id", "in": "path", - "description": "Campaign ID or slug", + "description": "Notification ID", "required": true, "schema": { - "type": "string" + "type": "string", + "format": "uuid" } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkThankDonorsRequest" - } - } - } - }, "responses": { - "200": { - "description": "Thank you messages sent successfully", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DonationResource" - } - } - } - } - }, - "403": { - "description": "Forbidden" + "204": { + "description": "Notification deleted" } }, "security": [ { - "sanctum": [] + "bearerAuth": [] } ] } @@ -1619,6 +1600,229 @@ } } } + }, + "/api/campaigns/{identifier}": { + "get": { + "tags": [ + "Campaigns" + ], + "summary": "Get campaign by slug or ID", + "operationId": "getCampaign", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "Campaign slug or ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Campaign retrieved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CampaignResource" + } + } + } + } + } + } + }, + "/api/campaigns/{identifier}/donations": { + "get": { + "tags": [ + "Donations" + ], + "summary": "List campaign donations", + "operationId": "listCampaignDonations", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "Campaign ID or slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Donations retrieved successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CampaignDonationResource" + } + } + }, + "type": "object" + } + } + } + } + } + } + }, + "/api/campaigns/{identifier}/donations/thankable": { + "get": { + "tags": [ + "Donations" + ], + "summary": "List thankable donations for a campaign", + "operationId": "listThankableDonations", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "Campaign ID or slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Thankable donations retrieved successfully", + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DonationResource" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/donations/{id}/thank": { + "post": { + "tags": [ + "Donations" + ], + "summary": "Send thank you message to a donor", + "operationId": "thankDonor", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Donation ID", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ThankDonorRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Thank you message sent successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DonationResource" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "422": { + "description": "Validation error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/campaigns/{identifier}/donations/thank": { + "post": { + "tags": [ + "Donations" + ], + "summary": "Send thank you message to multiple donors", + "operationId": "bulkThankDonors", + "parameters": [ + { + "name": "identifier", + "in": "path", + "description": "Campaign ID or slug", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkThankDonorsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Thank you messages sent successfully", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DonationResource" + } + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } } }, "components": { @@ -1718,26 +1922,6 @@ }, "type": "object" }, - "BulkThankDonorsRequest": { - "required": [ - "message", - "donationIds" - ], - "properties": { - "message": { - "type": "string", - "maxLength": 2000, - "minLength": 10 - }, - "donationIds": { - "type": "array", - "items": { - "type": "integer" - } - } - }, - "type": "object" - }, "DonorRequest": { "required": [ "name", @@ -1798,19 +1982,6 @@ }, "type": "object" }, - "ThankDonorRequest": { - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string", - "maxLength": 2000, - "minLength": 10 - } - }, - "type": "object" - }, "StoreReportRequest": { "required": [ "type", @@ -2220,6 +2391,37 @@ }, "type": "object" }, + "NotificationResource": { + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string", + "enum": [ + "donation", + "campaign", + "withdrawal", + "system" + ] + }, + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "isRead": { + "type": "boolean" + } + }, + "type": "object" + }, "PaymentResource": { "properties": { "method": { @@ -2431,6 +2633,39 @@ } }, "type": "object" + }, + "BulkThankDonorsRequest": { + "required": [ + "message", + "donationIds" + ], + "properties": { + "message": { + "type": "string", + "maxLength": 2000, + "minLength": 10 + }, + "donationIds": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "type": "object" + }, + "ThankDonorRequest": { + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string", + "maxLength": 2000, + "minLength": 10 + } + }, + "type": "object" } }, "securitySchemes": { @@ -2473,6 +2708,10 @@ { "name": "Leaderboard", "description": "Leaderboard endpoints" + }, + { + "name": "Notifications", + "description": "Notification management endpoints" } ] -} \ No newline at end of file +} diff --git a/api/routes/api.php b/api/routes/api.php index 03fb7ea..7e7d7c1 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\API\Comment\CommentController; use App\Http\Controllers\API\Comment\CommentReactionController; use App\Http\Controllers\API\Donation\DonationController; +use App\Http\Controllers\API\Notification\NotificationController; use App\Http\Controllers\API\Donation\DonationThankController; use App\Http\Controllers\API\Payment\WooviWebhookController; use App\Http\Controllers\API\ReportController; @@ -46,3 +47,8 @@ Route::get('/leaderboard/campaigns', [LeaderboardController::class, 'topCampaigns']); Route::get('/leaderboard/donors', [LeaderboardController::class, 'topDonors']); Route::get('/leaderboard/creators', [LeaderboardController::class, 'topCreators']); + +Route::get('/notifications', [NotificationController::class, 'index']); +Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead']); +Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead']); +Route::delete('/notifications/{id}', [NotificationController::class, 'destroy']); diff --git a/web/i18n/en.json b/web/i18n/en.json index c1b992b..b8c8566 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -1203,5 +1203,13 @@ "description": "If you have questions about our Privacy Policy or about the processing of your personal data, contact our Data Protection Officer (DPO).", "button": "Contact us" } + }, + "notifications_page": { + "title": "Notifications", + "subtitle": "Follow all updates about your campaigns and donations.", + "new_count": "{count, plural, one {# new} other {# new}}", + "mark_all_read": "Mark all as read", + "mark_as_read": "Mark as read", + "empty": "You don't have any notifications at the moment." } } \ No newline at end of file diff --git a/web/i18n/es.json b/web/i18n/es.json index a766a95..ed073b5 100644 --- a/web/i18n/es.json +++ b/web/i18n/es.json @@ -1203,5 +1203,13 @@ "description": "Si tienes preguntas sobre nuestra Política de Privacidad o sobre el tratamiento de tus datos personales, contacta con nuestro Responsable de Protección de Datos (DPO).", "button": "Contáctanos" } + }, + "notifications_page": { + "title": "Notificaciones", + "subtitle": "Sigue todas las actualizaciones sobre tus campañas y donaciones.", + "new_count": "{count, plural, one {# nueva} other {# nuevas}}", + "mark_all_read": "Marcar todas como leídas", + "mark_as_read": "Marcar como leída", + "empty": "No tienes notificaciones en este momento." } } \ No newline at end of file diff --git a/web/i18n/pt.json b/web/i18n/pt.json index 2183b2e..edf194f 100644 --- a/web/i18n/pt.json +++ b/web/i18n/pt.json @@ -1203,5 +1203,13 @@ "description": "Se você tiver dúvidas sobre nossa Política de Privacidade ou sobre o tratamento de seus dados pessoais, entre em contato com nosso Encarregado de Proteção de Dados (DPO).", "button": "Fale conosco" } + }, + "notifications_page": { + "title": "Notificações", + "subtitle": "Acompanhe todas as atualizações sobre suas campanhas e doações.", + "new_count": "{count, plural, one {# nova} other {# novas}}", + "mark_all_read": "Marcar todas como lidas", + "mark_as_read": "Marcar como lida", + "empty": "Você não tem notificações no momento." } } \ No newline at end of file diff --git a/web/src/app/profile/notifications/notification-item.tsx b/web/src/app/profile/notifications/notification-item.tsx index 052522c..f5ef0de 100644 --- a/web/src/app/profile/notifications/notification-item.tsx +++ b/web/src/app/profile/notifications/notification-item.tsx @@ -2,21 +2,12 @@ import { Card, CardBody, Button } from '@heroui/react'; import { Heart, Megaphone, DollarSign, AlertCircle, Trash2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { dayjs } from '@/lib/dayjs'; +import type { NotificationResource, NotificationResourceTypeEnumKey } from '@/lib/http/generated/models/NotificationResource'; -type NotificationType = 'donation' | 'campaign' | 'system' | 'withdrawal'; - -type Notification = { - id: string; - type: NotificationType; - title: string; - message: string; - timestamp: string; - isRead: boolean; -}; - -const notificationIcons: Record = { +const notificationIcons: Record = { donation: , campaign: , withdrawal: , @@ -35,12 +26,14 @@ const formatTimestamp = (timestamp: string) => { }; type NotificationItemProps = { - notification: Notification; + notification: NotificationResource; onMarkAsRead: (id: string) => void; onDelete: (id: string) => void; }; const NotificationItem = ({ notification, onMarkAsRead, onDelete }: NotificationItemProps) => { + const t = useTranslations('notifications_page'); + return (
- {notificationIcons[notification.type]} + {notificationIcons[notification.type!]}
@@ -72,7 +65,7 @@ const NotificationItem = ({ notification, onMarkAsRead, onDelete }: Notification

- {formatTimestamp(notification.timestamp)} + {formatTimestamp(notification.timestamp!)}
{!notification.isRead && ( @@ -80,10 +73,10 @@ const NotificationItem = ({ notification, onMarkAsRead, onDelete }: Notification size="sm" variant="flat" color="primary" - onPress={() => onMarkAsRead(notification.id)} + onPress={() => onMarkAsRead(notification.id!)} className="text-xs" > - Marcar como lida + {t('mark_as_read')} )} @@ -104,4 +97,4 @@ const NotificationItem = ({ notification, onMarkAsRead, onDelete }: Notification ); }; -export { NotificationItem, type Notification, type NotificationType }; +export { NotificationItem }; diff --git a/web/src/app/profile/notifications/notifications-list.tsx b/web/src/app/profile/notifications/notifications-list.tsx index a29fcfc..18cb314 100644 --- a/web/src/app/profile/notifications/notifications-list.tsx +++ b/web/src/app/profile/notifications/notifications-list.tsx @@ -1,75 +1,47 @@ 'use client'; -import { useState } from 'react'; -import { Card, CardBody, Button, Chip } from '@heroui/react'; +import { Card, CardBody, Button, Chip, Spinner } from '@heroui/react'; import { Bell, Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useQueryClient } from '@tanstack/react-query'; -import { NotificationItem, type Notification } from './notification-item'; - -const initialNotifications: Notification[] = [ - { - id: '1', - type: 'donation', - title: 'Nova doação recebida!', - message: 'Você recebeu uma doação de R$ 50,00 na campanha "Ajuda para Maria"', - timestamp: '2025-02-08T10:30:00', - isRead: false, - }, - { - id: '2', - type: 'campaign', - title: 'Campanha aprovada', - message: 'Sua campanha "Tratamento do João" foi aprovada e já está ativa!', - timestamp: '2025-02-07T15:45:00', - isRead: false, - }, - { - id: '3', - type: 'withdrawal', - title: 'Saque processado', - message: 'Seu saque de R$ 300,00 foi processado com sucesso.', - timestamp: '2025-02-06T09:20:00', - isRead: true, - }, - { - id: '4', - type: 'donation', - title: 'Nova doação recebida!', - message: 'Você recebeu uma doação de R$ 100,00 na campanha "Reforma escola"', - timestamp: '2025-02-05T14:10:00', - isRead: true, - }, - { - id: '5', - type: 'system', - title: 'Atualização de política', - message: 'Atualizamos nossa política de privacidade. Clique para saber mais.', - timestamp: '2025-02-04T11:00:00', - isRead: true, - }, -]; +import { useListNotifications, listNotificationsQueryKey } from '@/lib/http/generated/hooks/useListNotifications'; +import { useMarkNotificationAsRead } from '@/lib/http/generated/hooks/useMarkNotificationAsRead'; +import { useMarkAllNotificationsAsRead } from '@/lib/http/generated/hooks/useMarkAllNotificationsAsRead'; +import { useDeleteNotification } from '@/lib/http/generated/hooks/useDeleteNotification'; +import { NotificationItem } from './notification-item'; const NotificationsList = () => { - const [notifications, setNotifications] = useState(initialNotifications); + const t = useTranslations('notifications_page'); + const queryClient = useQueryClient(); + + const { data, isLoading } = useListNotifications(); - const unreadCount = notifications.filter((n) => !n.isRead).length; + const notifications = data?.data ?? []; + const unreadCount = data?.meta?.unreadCount ?? 0; - const markAsRead = (id: string) => { - setNotifications((prev) => - prev.map((notif) => - notif.id === id ? { ...notif, isRead: true } : notif - ) - ); + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: listNotificationsQueryKey() }); }; - const markAllAsRead = () => { - setNotifications((prev) => - prev.map((notif) => ({ ...notif, isRead: true })) - ); + const { mutate: markAsRead } = useMarkNotificationAsRead({ + mutation: { onSuccess: invalidate }, + }); + + const { mutate: markAllAsRead } = useMarkAllNotificationsAsRead({ + mutation: { onSuccess: invalidate }, + }); + + const { mutate: deleteNotif } = useDeleteNotification({ + mutation: { onSuccess: invalidate }, + }); + + const handleMarkAsRead = (id: string) => { + markAsRead({ id }); }; - const deleteNotification = (id: string) => { - setNotifications((prev) => prev.filter((notif) => notif.id !== id)); + const handleDelete = (id: string) => { + deleteNotif({ id }); }; return ( @@ -77,15 +49,15 @@ const NotificationsList = () => {

- Notificações + {t('title')} {unreadCount > 0 && ( - {unreadCount} nova{unreadCount > 1 ? 's' : ''} + {t('new_count', { count: unreadCount })} )}

- Acompanhe todas as atualizações sobre suas campanhas e doações. + {t('subtitle')}

{unreadCount > 0 && ( @@ -93,19 +65,23 @@ const NotificationsList = () => { color="primary" variant="flat" startContent={} - onPress={markAllAsRead} + onPress={() => markAllAsRead()} > - Marcar todas como lidas + {t('mark_all_read')} )}
- {notifications.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : notifications.length === 0 ? (

- Você não tem notificações no momento. + {t('empty')}

@@ -115,8 +91,8 @@ const NotificationsList = () => { ))}
diff --git a/web/src/app/profile/notifications/page.tsx b/web/src/app/profile/notifications/page.tsx index 7a4120b..4fe7c2f 100644 --- a/web/src/app/profile/notifications/page.tsx +++ b/web/src/app/profile/notifications/page.tsx @@ -2,12 +2,6 @@ import { ProfileSidebar } from '../profile-sidebar'; import { NotificationsList } from './notifications-list'; const NotificationsPage = () => { - const userData = { - name: 'Cristiano', - followedCampaigns: 0, - donationsCount: 3, - }; - return (