Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.35'
version = '0.0.36'
description = 'Flextuma App'

java {
Expand Down
134 changes: 134 additions & 0 deletions docs/frontend-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,137 @@ Examples:
- `/settings/profile`

These routes will return the client app entry file, while `/api/**` remains reserved for backend APIs.

## 5. Personal Notifications Integration

The app also supports user-facing personal notifications for dropdowns, badges, and a full notification center.

### Personal Notifications List

- `GET /api/personalNotifications?page=1&pageSize=10`

Response shape:

```json
{
"page": 1,
"total": 12,
"pageSize": 10,
"personalNotifications": [
{
"id": "0dbe588d-7b7e-4bb9-a6e9-9fa1228a19f4",
"title": "Low Balance Alert",
"message": "Your wallet balance is below TZS 10,000. Current balance: TZS 9500.",
"type": "LOW_BALANCE_ALERT",
"linkUrl": "/finance/wallet",
"readAt": null,
"created": "2026-03-30T16:20:00",
"updated": "2026-03-30T16:20:00"
}
]
}
```

Use this for:
- full “View All Notifications” page
- paginated notification center
- notification history screens

### Personal Notifications Summary

- `GET /api/personalNotifications/summary?pageSize=5`

Use this for the header bell dropdown like the one in your screenshot.

```json
{
"unreadCount": 3,
"notifications": [
{
"id": "0dbe588d-7b7e-4bb9-a6e9-9fa1228a19f4",
"title": "Low Balance Alert",
"message": "Your wallet balance is below TZS 10,000. Current balance: TZS 9500.",
"type": "LOW_BALANCE_ALERT",
"linkUrl": "/finance/wallet",
"readAt": null,
"created": "2026-03-30T16:20:00",
"updated": "2026-03-30T16:20:00"
},
{
"id": "1f0e8a6a-1d43-48f4-a0ae-0f1b6de8697b",
"title": "Campaign Completed",
"message": "Summer Sale 2024 has finished sending.",
"type": "CAMPAIGN_COMPLETED",
"linkUrl": "/campaigns",
"readAt": null,
"created": "2026-03-30T15:55:00",
"updated": "2026-03-30T15:55:00"
}
]
}
```

### Mark One Notification as Read

- `POST /api/personalNotifications/{id}/read`

Recommended when:
- user clicks a notification item
- user opens a notification detail
- user navigates through a notification deep link

### Mark All Notifications as Read

- `POST /api/personalNotifications/readAll`

Example response:

```json
{
"message": "Notifications marked as read",
"updated": 3
}
```

### Example Frontend Flow

```javascript
async function loadPersonalNotificationSummary(pageSize = 5) {
const response = await fetch(
`/api/personalNotifications/summary?pageSize=${pageSize}`,
{ credentials: 'include' }
);

if (!response.ok) {
throw new Error('Failed to load personal notifications');
}

return response.json();
}

async function markNotificationRead(id) {
const response = await fetch(`/api/personalNotifications/${id}/read`, {
method: 'POST',
credentials: 'include'
});

if (!response.ok) {
throw new Error('Failed to mark notification as read');
}

return response.json();
}
```

Recommended UI behavior:
- use `unreadCount` for the blue badge count
- render `notifications` in the dropdown panel
- show relative time from `created`
- navigate to `linkUrl` when present
- for “View All Notifications”, call `GET /api/personalNotifications?page=1&pageSize=10`

### Current Automatic Notification Types

These are generated from real backend events:
- `LOW_BALANCE_ALERT`
- `CAMPAIGN_COMPLETED`
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.flexcodelabs.flextuma.core.entities.notification;

import java.time.LocalDateTime;

import com.flexcodelabs.flextuma.core.entities.base.Owner;
import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Entity
@Table(name = "personalnotification", uniqueConstraints = {
@UniqueConstraint(name = "unique_personal_notification_code", columnNames = "code")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PersonalNotification extends Owner {

public static final String PLURAL = "personalNotifications";
public static final String NAME_PLURAL = "Personal Notifications";
public static final String NAME_SINGULAR = "Personal Notification";

public static final String ALL = "ALL";
public static final String READ = ALL;
public static final String ADD = ALL;
public static final String DELETE = ALL;
public static final String UPDATE = ALL;

@Column(nullable = false)
private String title;

@Column(nullable = false, columnDefinition = "TEXT")
private String message;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 50)
private PersonalNotificationType type;

@Column(name = "link_url")
private String linkUrl;

@Column(name = "read_at")
private LocalDateTime readAt;

@Column(name = "metadata", columnDefinition = "TEXT")
private String metadata;

public boolean isUnread() {
return readAt == null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.flexcodelabs.flextuma.core.enums;

public enum PersonalNotificationType {
LOW_BALANCE_ALERT,
CAMPAIGN_COMPLETED,
SYSTEM_UPDATE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.flexcodelabs.flextuma.core.repositories;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification;
import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType;

@Repository
public interface PersonalNotificationRepository extends BaseRepository<PersonalNotification, UUID>,
org.springframework.data.jpa.repository.JpaSpecificationExecutor<PersonalNotification> {

long countByCreatedByAndReadAtIsNull(User user);

List<PersonalNotification> findByCreatedByOrderByCreatedDesc(User user, Pageable pageable);

Optional<PersonalNotification> findByIdAndCreatedBy(UUID id, User user);

Optional<PersonalNotification> findByCreatedByAndTypeAndReadAtIsNull(User user, PersonalNotificationType type);

@org.springframework.data.jpa.repository.Modifying
@org.springframework.data.jpa.repository.Query("""
UPDATE PersonalNotification p
SET p.readAt = :readAt
WHERE p.createdBy = :user AND p.readAt IS NULL
""")
int markAllAsRead(
@org.springframework.data.repository.query.Param("user") User user,
@org.springframework.data.repository.query.Param("readAt") java.time.LocalDateTime readAt);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@

@Repository
public interface SmsCampaignRepository extends BaseRepository<SmsCampaign, UUID>,
org.springframework.data.jpa.repository.JpaSpecificationExecutor<SmsCampaign> {
org.springframework.data.jpa.repository.JpaSpecificationExecutor<SmsCampaign> {

@Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now")
List<SmsCampaign> findDueCampaigns(
@Param("status") SmsCampaignStatus status,
@Param("now") LocalDateTime now,
Pageable pageable);
@Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now")
List<SmsCampaign> findDueCampaigns(
@Param("status") SmsCampaignStatus status,
@Param("now") LocalDateTime now,
Pageable pageable);

long countByCreatedByAndStatusIn(User user, Collection<SmsCampaignStatus> statuses);
long countByCreatedByAndStatusIn(User user, Collection<SmsCampaignStatus> statuses);

long countByStatusIn(Collection<SmsCampaignStatus> statuses);
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ List<SmsLog> findDueMessages(

long countByCreatedByAndStatusInAndCreatedGreaterThanEqual(User user, Collection<SmsLogStatus> statuses,
LocalDateTime created);

long countByStatus(SmsLogStatus status);

long countByStatusIn(Collection<SmsLogStatus> statuses);

long countByStatusInAndCreatedGreaterThanEqual(Collection<SmsLogStatus> statuses, LocalDateTime created);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.flexcodelabs.flextuma.core.entities.finance.Wallet;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
Expand All @@ -16,4 +17,10 @@ public interface WalletRepository extends JpaRepository<Wallet, UUID>, JpaSpecif
List<Wallet> findByCreatedByAndBalanceGreaterThan(User user, java.math.BigDecimal balance);

java.util.Optional<Wallet> findTopByCreatedByOrderByCreatedDesc(User user);

@Query("SELECT COALESCE(SUM(w.balance), 0) FROM Wallet w WHERE w.balance IS NOT NULL")
java.math.BigDecimal sumAllBalances();

@Query("SELECT w FROM Wallet w WHERE w.currency IS NOT NULL ORDER BY w.created DESC")
java.util.List<Wallet> findTopByCurrencyOrderByCreatedDesc();
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,44 @@ public void initialize(Object entity, int depth) {

BeanWrapper wrapper = new BeanWrapperImpl(entity);
for (Attribute<?, ?> attribute : managedType.getAttributes()) {
if (!attribute.isAssociation() || !wrapper.isReadableProperty(attribute.getName())) {
continue;
}
processAttribute(wrapper, attribute, depth);
}
}

Object value = wrapper.getPropertyValue(attribute.getName());
if (value == null) {
continue;
}
private void processAttribute(BeanWrapper wrapper, Attribute<?, ?> attribute, int depth) {
if (!shouldProcessAttribute(wrapper, attribute)) {
return;
}

Hibernate.initialize(value);
if (depth == 0) {
continue;
}
Object value = wrapper.getPropertyValue(attribute.getName());
if (value == null) {
return;
}

if (value instanceof Collection<?> collection) {
for (Object item : collection) {
initializeSingularAssociations(item, depth - 1);
}
continue;
}
Hibernate.initialize(value);
if (depth > 0) {
processAttributeValue(value, depth);
}
}

private boolean shouldProcessAttribute(BeanWrapper wrapper, Attribute<?, ?> attribute) {
return attribute.isAssociation() && wrapper.isReadableProperty(attribute.getName());
}

private void processAttributeValue(Object value, int depth) {
if (value instanceof Collection<?> collection) {
processCollection(collection, depth);
} else {
initializeSingularAssociations(value, depth - 1);
}
}

private void processCollection(Collection<?> collection, int depth) {
for (Object item : collection) {
initializeSingularAssociations(item, depth - 1);
}
}

private void initializeSingularAssociations(Object entity, int depth) {
if (entity == null || depth < 0) {
return;
Expand All @@ -73,18 +86,17 @@ private void initializeSingularAssociations(Object entity, int depth) {

BeanWrapper wrapper = new BeanWrapperImpl(entity);
for (Attribute<?, ?> attribute : managedType.getAttributes()) {
if (!attribute.isAssociation() || attribute.isCollection() || !wrapper.isReadableProperty(attribute.getName())) {
if (!attribute.isAssociation() || attribute.isCollection()
|| !wrapper.isReadableProperty(attribute.getName())) {
continue;
}

Object value = wrapper.getPropertyValue(attribute.getName());
if (value == null) {
continue;
}

Hibernate.initialize(value);
if (depth > 0) {
initializeSingularAssociations(value, depth - 1);
if (value != null) {
Hibernate.initialize(value);
if (depth > 0) {
initializeSingularAssociations(value, depth - 1);
}
}
}
}
Expand Down
Loading