diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fbb2da..3c0005f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,12 +13,9 @@ jobs: if: | contains(github.event.head_commit.message, 'release') && github.event_name == 'push' runs-on: ubuntu-latest - strategy: - matrix: - node-version: [20.x] steps: - uses: actions/checkout@v4 - - name: πŸ”€ + - name: Create Pull Request uses: BaharaJr/create-pr@0.0.1 with: GITHUB_TOKEN: ${{secrets.TOKEN}} @@ -26,18 +23,17 @@ jobs: KEYWORD: release CHECK_MESSAGE: - if: | - github.event_name == 'pull_request' + if: github.event_name == 'pull_request' name: COMMIT CHECK runs-on: ubuntu-latest outputs: sms: ${{ steps.sms_id.outputs.sms }} steps: - - name: 🚚 + - name: Checkout PR Head uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} - - name: ❇️ + - name: Get Commit Message id: sms_id run: echo "sms=$(git show -s --format=%s)" >> "$GITHUB_OUTPUT" @@ -46,11 +42,14 @@ jobs: permissions: contents: write needs: CHECK_MESSAGE + # Only run if the PR commit message contains 'release' if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') }} steps: - - name: Checkout code + - name: Checkout Source Branch uses: actions/checkout@v4 with: + # Check out the actual branch (develop), not the merge commit + ref: ${{ github.head_ref }} fetch-depth: 0 token: ${{ secrets.TOKEN }} @@ -100,8 +99,9 @@ jobs: git commit -m "Release v$NEW_VERSION [skip ci]" git tag "v$NEW_VERSION" - # 5. Push changes - git push origin main + # 5. Push changes back to the source branch (develop) + # Use HEAD:${{ github.head_ref }} to ensure it pushes to the PR source branch + git push origin HEAD:${{ github.head_ref }} git push origin "v$NEW_VERSION" - name: Build with Gradle @@ -127,4 +127,4 @@ jobs: echo "Pushing images..." docker push $IMAGE_NAME:$VERSION - docker push $IMAGE_NAME:latest + docker push $IMAGE_NAME:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2924416..5a8224f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ out/ http -ROADMAP \ No newline at end of file +ROADMAP + +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..235ea71 --- /dev/null +++ b/README.md @@ -0,0 +1,405 @@ +# Flextuma + +Flextuma is a configurable, multi-tenant messaging gateway built on Spring Boot. It serves multiple organisations from a single deployment with full data isolation, and supports SMS delivery today with WhatsApp and Email on the roadmap. + +--- + +## Prerequisites + +| Requirement | Version | +|---|---| +| Java | 17+ | +| Docker & Docker Compose | Any recent version | +| Gradle | Provided via wrapper (`./gradlew`) | + +The application requires **PostgreSQL** and **Redis** to be available before startup. These are not provisioned by the included `compose.yaml` β€” they must be provided externally. + +--- + +## Getting Started + +### 1. Clone the repository + +```bash +git clone +cd flextuma +``` + +### 2. Configure environment variables + +Create a `.env` file in the root directory or export the variables in your shell: + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SPRING_DATASOURCE_URL` | βœ… | β€” | JDBC URL, e.g. `jdbc:postgresql://host:5432/db` | +| `SPRING_DATASOURCE_USERNAME` | βœ… | β€” | Database username | +| `SPRING_DATASOURCE_PASSWORD` | βœ… | β€” | Database password | +| `SPRING_DATA_REDIS_HOST` | βœ… | β€” | Redis hostname | +| `SPRING_DATA_REDIS_PORT` | ❌ | `6379` | Redis port | +| `HIKARI_MAX_POOL` | ❌ | `10` | Max JDBC connection pool size | +| `SMS_PRICE_PER_SEGMENT` | ❌ | `20.0` | Price per SMS segment (in TZS) | + +### 3. Build the application + +```bash +./gradlew clean build -x test +``` + +### 4. Run with Docker Compose + +```bash +docker compose up --build +``` + +The application starts on **http://localhost:8080**. + +### 5. Local development (without Docker) + +```bash +./gradlew bootRun +``` + +### 6. Watch mode (live rebuild) + +```bash +./gradlew build -t +``` + +--- + +## Architecture Overview + +Flextuma follows a layered architecture with a shared `core` library and feature-based `modules`. + +``` +src/main/java/com/flexcodelabs/flextuma/ +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ config/ # App startup, Jackson, request logging, cookie auth config +β”‚ β”œβ”€β”€ context/ # TenantContext (ThreadLocal β€” reserved, not yet active) +β”‚ β”œβ”€β”€ annotations/ # @FeatureGate β€” method-level feature flag annotation +β”‚ β”œβ”€β”€ aspects/ # FeatureGateAspect β€” AOP enforcement of @FeatureGate +β”‚ β”œβ”€β”€ controllers/ # BaseController β€” generic CRUD for all modules +β”‚ β”œβ”€β”€ dtos/ # Pagination response wrapper +β”‚ β”œβ”€β”€ entities/ +β”‚ β”‚ β”œβ”€β”€ base/ # BaseEntity, NameEntity, Owner (MappedSuperclasses) +β”‚ β”‚ β”œβ”€β”€ auth/ # User, Role, Privilege, Organisation +β”‚ β”‚ β”œβ”€β”€ connector/ # ConnectorConfig +β”‚ β”‚ β”œβ”€β”€ contact/ # Contact +β”‚ β”‚ β”œβ”€β”€ feature/ # TenantFeature β€” per-org feature flags +β”‚ β”‚ β”œβ”€β”€ metadata/ # Tag, ListEntity +β”‚ β”‚ └── sms/ # SmsConnector, SmsTemplate, SmsLog +β”‚ β”œβ”€β”€ enums/ # AuthType, CategoryEnum, UserType, FilterOperator +β”‚ β”œβ”€β”€ exceptions/ # Global exception handling +β”‚ β”œβ”€β”€ helpers/ # Specification builder, filters, masking, template utils +β”‚ β”œβ”€β”€ interceptors/ # Entity audit interceptor +β”‚ β”œβ”€β”€ repositories/ # BaseRepository + all JPA repositories +β”‚ β”œβ”€β”€ security/ # SecurityConfig, SecurityUtils, CustomSecurityExceptionHandler +β”‚ β”œβ”€β”€ senders/ # SmsSender interface + BeemSender, NextSmsSender +β”‚ └── services/ # BaseService, SmsSenderRegistry, DataSeederService +└── modules/ + β”œβ”€β”€ auth/ # User, Role, Privilege, Organisation controllers & services + β”œβ”€β”€ connector/ # ConnectorConfig + DataHydratorService + β”œβ”€β”€ contact/ # Contact management + β”œβ”€β”€ feature/ # TenantFeature β€” per-org feature flag management + β”œβ”€β”€ metadata/ # Tags and Lists + β”œβ”€β”€ notification/ # Notification management + └── sms/ # SmsConnector, SmsTemplate controllers & services +``` + +--- + +## Core Concepts + +### BaseEntity & Inheritance Chain + +All entities extend one of: + +| Class | Adds | +|---|---| +| `BaseEntity` | `id` (UUID), `created`, `updated`, `active`, `code` | +| `NameEntity extends BaseEntity` | `name`, `description` | +| `Owner extends BaseEntity` | `createdBy` (User), `updatedBy` (User) with `@CreatedBy` audit | + +### BaseController & BaseService + +Every resource gets full CRUD for free by extending these: + +| HTTP Method | Endpoint | Action | +|---|---|---| +| `GET` | `/api/{resource}` | Paginated list with optional `filter` and `fields` params | +| `GET` | `/api/{resource}/{id}` | Get by ID | +| `POST` | `/api/{resource}` | Create | +| `PUT` | `/api/{resource}/{id}` | Update (null-safe partial update) | +| `DELETE` | `/api/{resource}/{id}` | Delete (with optional pre-delete validation) | + +**Filter syntax:** `?filter=field:OPERATOR:value` β€” supports `EQ`, `NE`, `LIKE`, `ILIKE`, `IN`, `GT`, `LT`. + +### Permission System + +Every resource defines permission constants (`READ_*`, `ADD_*`, `UPDATE_*`, `DELETE_*`). `BaseService` checks these against the current user's granted authorities before every operation. Users with `SUPER_ADMIN` or `ALL` bypass all checks. + +--- + +## Feature Flags + +Flextuma supports per-organisation feature flags via the `@FeatureGate` AOP annotation. This lets you gate specific capabilities per tenant without a code deploy β€” useful for subscription tiers, beta rollouts, or temporarily suspending access. + +### How it works + +- Annotate any service method with `@FeatureGate("FEATURE_KEY")` +- Spring AOP intercepts the call and checks the `tenantfeature` table for the calling user's organisation +- If a record with `enabled = false` exists β†’ `403 Forbidden` is thrown before the method runs +- If **no record exists** β†’ the feature is **allowed** (default-open: you only need records for restrictions) +- Users with no organisation (SUPER_ADMIN, system users) always bypass the check + +### Developer workflow β€” adding a new gated feature + +**Step 1.** Pick a `SCREAMING_SNAKE_CASE` key and annotate the service method: + +```java +// modules/notification/services/NotificationService.java +@Async +@FeatureGate("BULK_CAMPAIGN") +public void sendCampaign(Campaign campaign, String username) { + // 403 thrown here automatically if org has BULK_CAMPAIGN disabled +} +``` + +**Step 2.** Add it to the feature keys table in this README (see below). + +That's it. No DB schema changes, no config files. + +--- + +### The two-layer access model + +Feature flags and permissions work together but guard different things: + +| Layer | Enforced by | Question answered | +|---|---|---| +| **Permission** | `BaseService.checkPermission()` | Does *this user's role* allow this action? | +| **Feature flag** | `@FeatureGate` AOP | Does *this organisation's plan* include this capability? | + +```java +@FeatureGate("BULK_CAMPAIGN") // ← org-level: is this feature enabled for the tenant? +public void sendCampaign(...) { + checkPermission("SEND_BULK"); // ← user-level: does the user have the right role? + ... +} +``` + +| Scenario | Result | +|---|---| +| User lacks `SEND_BULK` role | `checkPermission()` throws 403 | +| User has role, but org is restricted | `@FeatureGate` throws 403 | +| User has role AND org has feature | βœ… Proceeds | + +--- + +### Managing flags via API + +```http +### Create a restriction (disable a feature for an org) +POST /api/tenantFeatures +Content-Type: application/json + +{ + "organisation": { "id": "" }, + "featureKey": "WHATSAPP_SEND", + "enabled": false +} + +### Re-enable (e.g. after plan upgrade) +PUT /api/tenantFeatures/ +Content-Type: application/json + +{ "enabled": true } + +### List all flags for inspection +GET /api/tenantFeatures?filter=organisation:EQ: +``` + +--- + +### Available feature keys + +Document every key here when you introduce it: + +| Key | Controls | Default | +|---|---|---| +| `BULK_CAMPAIGN` | Bulk messaging to contact lists/tags | Open | +| `WHATSAPP_SEND` | WhatsApp channel sending | Open | +| `EMAIL_SEND` | Email channel sending | Open | +| `CONNECTOR_PULL` | Fetching contacts via external connector | Open | + +> **Convention:** All features are open by default. Only create `TenantFeature` records when you need to *restrict* an org. This keeps the table minimal and the logic simple. + +--- + + +## Modules + +### Auth (`/api/users`, `/api/roles`, `/api/privileges`, `/api/organisations`) + +Manages users, roles, privilege-based RBAC, and organisation membership. + +- **`User`** β€” linked to an `Organisation` (one-to-many: many users per org). `UserType` enum (e.g. `SYSTEM`) identifies platform-level admins. +- **`Organisation`** β€” the multi-tenancy anchor. Each SACCO is one Organisation. All users of that SACCO share the same `organisationId`. +- **`Role`** β†’ **`Privilege`** β€” fine-grained permission strings enforced in `BaseService`. + +### Connector (`/api/connectorConfigs`) + +Configures how Flextuma connects to each organisation's external ERP/data source. + +- **`ConnectorConfig`** β€” stores the base URL, endpoint, `AuthType` (`NONE`, `BASIC`, `BEARER`, `API_KEY`), credentials (masked in responses), and a **JSONPath mapping list** (`List`) stored as JSONB. +- **`DataHydratorService`** β€” given a `tenantId` and a `memberId`, fetches the external ERP, applies the JSONPath mappings, and returns a `Map` of system keys to values. Used to populate SMS template placeholders. + +### SMS (`/api/smsConnectors`, `/api/templates`) + +Manages SMS provider configurations and message templates. + +- **`SmsConnector`** β€” provider configuration (URL, API key/secret, sender ID, extra settings). One connector can be marked active at a time. +- **`SmsTemplate`** β€” message templates with `{placeholder}` variables, categorised by `CategoryEnum` (`PROMOTIONAL`, etc.). System templates are protected from deletion. +- **`SmsLog`** β€” records every sent message: recipient, content, status, provider response, error, and linked template. +- **`SmsSenderRegistry`** β€” selects the active `SmsConnector` from the DB, finds the matching `SmsSender` implementation by provider name, and dispatches the message. + +### SMS Providers + +Two concrete `SmsSender` implementations: + +| Provider | Class | Auth Method | +|---|---|---| +| **Beem** | `BeemSender` | API key + secret (Basic Auth header) | +| **NextSMS** | `NextSmsSender` | Stub (logs output β€” for local testing) | + +Adding a new provider: implement `SmsSender`, annotate with `@Service`, and set the matching `provider` string on the `SmsConnector` record. + +### Connector Module β€” Data Hydration Flow + +``` +Request with memberId + β†’ ConnectorConfigRepository.findByTenantId(tenantId) + β†’ Build URL: config.url + config.endpoint.replace("{id}", memberId) + β†’ Apply auth headers (BEARER / API_KEY / BASIC / NONE) + β†’ Parse JSON response with Jayway JsonPath + β†’ Map to internal keys via FieldMapping list + β†’ Return Map for template rendering +``` + +--- + +## Security + +### Authentication + +| Client | Method | +|---|---| +| Browser / SPA | Session-based: POST credentials to `/api/login` β†’ receive HttpOnly `SESSION` cookie (backed by Redis) | +| API/testing | HTTP Basic Auth (`Authorization: Basic base64(user:pass)`) β€” also accepted for session creation | +| Webhooks / PAT | Personal Access Token (planned) | + +### CSRF + +CSRF protection uses `CookieCsrfTokenRepository` (token sent as `XSRF-TOKEN` cookie, readable by SPA). Exemptions: + +- `/api/login` β€” no session exists yet at this point +- `/api/webhooks/**` β€” reserved for PAT-authenticated provider callbacks + +### Tenant-Aware Resource Filtering + +Every paginated and list query automatically applies `TenantAwareSpecification`: + +| User | Sees | +|---|---| +| `SUPER_ADMIN` or `ALL` authority | All records (no restriction) | +| User with an Organisation | Records they created **or** records created by any member of the same organisation | +| User with no Organisation | Only their own records | +| Entities without `createdBy` (e.g. `Organisation`) | No restriction applied | + +This is enforced in `BaseService.buildTenantSpec()` β€” all subclass services benefit automatically. + +### Session Management + +- Sessions are stored in **Redis** (`@EnableRedisHttpSession`) +- Session cookie: `SESSION`, HttpOnly, `SameSite=Lax` +- Maximum **1 concurrent session** per user + +--- + +## Data Seeding + +On startup, `DataInitializer` runs `DataSeederService.seedSystemData()`, which executes `seed.sql` via JDBC to ensure system-level data (privileges, default roles, system user) is present before the application accepts requests. + +--- + +## Development Guide + +### Running tests + +```bash +./gradlew test +``` + +### API testing (`.http` files) + +HTTP request files are in the `/http` directory. Use IntelliJ's HTTP client or any compatible tool. The login endpoint does not require a CSRF token. All subsequent mutating requests (`POST`/`PUT`/`DELETE`) must include the `X-CSRF-TOKEN` header (value from the `XSRF-TOKEN` response cookie). + +```http +### Login +POST http://localhost:8080/api/login +Content-Type: application/json + +{"username": "admin", "password": "pass"} +``` + +### Adding a new module + +1. Create an entity in `core/entities/` extending `BaseEntity`, `NameEntity`, or `Owner` +2. Define permission constants (`READ_*`, `ADD_*`, etc.) on the entity +3. Create a `JpaRepository` in `core/repositories/` +4. Create a `Service extends BaseService` in `modules/.../services/` +5. Create a `Controller extends BaseController` in `modules/.../controllers/` + +--- + +## Roadmap + +See [`ROADMAP/roadmap.md`](ROADMAP/roadmap.md) for the full development roadmap, [`ROADMAP/architecture.md`](ROADMAP/architecture.md) for the multi-channel notification architecture, and [`ROADMAP/roadmap-audit.md`](ROADMAP/roadmap-audit.md) for the current implementation status of each item. + +**Recently completed:** +- [x] Admin Monitoring API enhancements (query by status, retry endpoint) +- [x] Scheduling Engine (future-dated campaigns) +- [x] Personal Access Token (PAT) entity and filter for API / gateway access +- [x] Per-organisation feature flagging via `@FeatureGate` AOP annotation +- [x] `TenantAwareSpecification` β€” automatic org-scoped data isolation +- [x] `DataHydratorService` β€” external ERP integration with JSONPath field mapping +- [x] Template placeholder engine (`{{variable}}` syntax with missing-variable detection) +- [x] SMS segment calculator (GSM-7 vs Unicode encoding) +- [x] Wallet & ledger system with pre-flight balance checks +- [x] Async SMS dispatch worker (`@Scheduled` + `SmsLog` status lifecycle) +- [x] Rate Limiter (Bucket4j per-tenant quotas) +- [x] Webhook DLR receiver & Recipient Resolver Trigger API (`/api/webhooks...`) +- [x] Character Count & Preview API (`/api/smsTemplates/preview` returning segment counts and `charactersRemaining` budget) + +**Immediate next steps:** +- [x] Implement real HTTP logic for `NextSmsSender` +- [ ] Database Partitioning for `sms_log` table +- [ ] Multi-channel support (WhatsApp/Email) + +--- + +## Wallet Management Example +The new `WalletService` handles crediting and debiting of accounts per organisation. +Currently, wallets must be topped up programmatically until an admin UI is built. + +Example of topping up an account with 100,000 TZS dynamically inside a Service: + +```java +@Autowired +private WalletService walletService; + +public void processManualTopup(User orgAdmin) { + BigDecimal amount = BigDecimal.valueOf(100000.00); + walletService.credit(orgAdmin, amount, "Manual Top Up", "REF-12345"); +} +``` diff --git a/build.gradle b/build.gradle index d91cadb..3c2f1e3 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.1' +version = '0.0.2' description = 'Flextuma App' java { @@ -34,6 +34,7 @@ dependencies { implementation 'org.springframework.session:spring-session-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.aspectj:aspectjweaver' implementation 'org.glassfish:jakarta.el:4.0.2' implementation 'com.jayway.jsonpath:json-path' compileOnly 'org.projectlombok:lombok' @@ -48,6 +49,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'com.fasterxml.jackson.core:jackson-databind:2.16.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'com.bucket4j:bucket4j-core:8.10.1' } @@ -69,7 +71,7 @@ sonarqube { properties { property "sonar.projectKey", "flexcodelabs_flextuma" property "sonar.organization", "flexcodelabs" - property "sonar.host.url", "https://sonarcloud.io" + property "sonar.host.url", "https://sonar.flexcodelabs.com" property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/jacoco/test/jacocoTestReport.xml" } } diff --git a/compose.yaml b/compose.yaml index ec698aa..11cf873 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,21 +6,23 @@ services: ports: - "8080:8080" restart: always + env_file: + - .env environment: - - SPRING_DATASOURCE_URL=jdbc:postgresql://main:5432/flexmalipo - - SPRING_DATASOURCE_USERNAME=postgres - - SPRING_DATASOURCE_PASSWORD=postgres + - SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL} + - SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD} - SPRING_DOCKER_COMPOSE_ENABLED=false - - HIKARI_MAX_POOL=10 - - HIKARI_MIN_IDLE=5 - - HIKARI_IDLE_TIMEOUT=300000 - - HIKARI_CONN_TIMEOUT=20000 + - HIKARI_MAX_POOL=${HIKARI_MAX_POOL:-10} + - HIKARI_MIN_IDLE=${HIKARI_MIN_IDLE:-5} + - HIKARI_IDLE_TIMEOUT=${HIKARI_IDLE_TIMEOUT:-300000} + - HIKARI_CONN_TIMEOUT=${HIKARI_CONN_TIMEOUT:-20000} - SPRING_DEVTOOLS_RESTART_ENABLED=true - SPRING_JPA_HIBERNATE_DDL_AUTO=update # - SPRING_JPA_SHOW_SQL=true - SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT=org.hibernate.dialect.PostgreSQLDialect - - SPRING_DATA_REDIS_HOST=redis - - SPRING_DATA_REDIS_PORT=6379 + - SPRING_DATA_REDIS_HOST=${SPRING_DATA_REDIS_HOST} + - SPRING_DATA_REDIS_PORT=${SPRING_DATA_REDIS_PORT:-6379} volumes: - ./src:/app/src - ./build/classes/java/main:/app/extracted/WEB-INF/classes diff --git a/docs/example-data.sql b/docs/example-data.sql new file mode 100644 index 0000000..070b459 --- /dev/null +++ b/docs/example-data.sql @@ -0,0 +1,16 @@ +-- Example Data for Flextuma + +-- 1. Example Organisation +INSERT INTO organisation (id, name, active, code) +VALUES (gen_random_uuid(), 'Flex Code Labs', true, 'FLEX01'); + +-- 2. Example SMS Connector (Beem) +INSERT INTO smsconnector (id, name, provider, apiKey, secretKey, active) +VALUES (gen_random_uuid(), 'Main Beem Account', 'BEEM', 'your_beem_api_key', 'your_beem_secret', true); + +-- 3. Example SMS Template +INSERT INTO smstemplate (id, name, code, content, active, system) +VALUES (gen_random_uuid(), 'Welcome SMS', 'WELCOME_SMS', 'Hello {{name}}, welcome to Flextuma!', true, true); + +-- 4. Admin User (if not exists) +-- Note: Password hashing is usually handled at runtime, so use the /api/users endpoint ideally. diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md new file mode 100644 index 0000000..7105db7 --- /dev/null +++ b/docs/frontend-integration.md @@ -0,0 +1,39 @@ +# Frontend Integration Guide + +This guide describes how to integrate the Flextuma API into a frontend application (e.g., React, Vue, or Next.js). + +## 1. Authentication Strategy + +### For Internal Dashboards (Session-based) +Internal tools should use the standard login process, which sets a secure `SESSION` cookie. +- **Login**: `POST /api/login` +- Following requests will automatically include the cookie via `credentials: 'include'`. + +### For External Apps / Programmatic Access (PAT-based) +External services should use Personal Access Tokens in the header. +- **Header**: `X-API-KEY: ` +- **Security**: Never expose your PAT in client-side code that is public. Use it only in secure, server-side environments or behind a proxy. + +## 2. Common Patterns + +### Handling SMS Scheduling +Users can schedule messages by passing a `scheduledAt` field in ISO-8601 format: +```javascript +const payload = { + phoneNumber: "255...", + templateCode: "...", + scheduledAt: new Date(Date.now() + 3600000).toISOString() // 1 hour from now +}; +``` + +### Real-time Preview +When building a template editor, use the `/api/smsTemplates/preview` endpoint for live character counting and segment calculation. +- Useful for showing users how much a message will cost. +- Avoids server-side "surprises" on message length. + +## 3. Error Handling +The API uses standard HTTP status codes: +- `400 Bad Request`: Missing variables, invalid data. +- `401 Unauthorized`: Invalid PAT or session. +- `403 Forbidden`: Insufficient permissions. +- `429 Too Many Requests`: Rate limit exceeded (Bucket4j). diff --git a/flextuma-api.http b/flextuma-api.http new file mode 100644 index 0000000..0ba483e --- /dev/null +++ b/flextuma-api.http @@ -0,0 +1,139 @@ +# Flextuma API Specification + +This file contains examples of common API requests for the Flextuma backend. + +### Authentication + +#### Log In +```http +POST /api/login +Content-Type: application/json + +{ + "username": "admin", + "password": "yourpassword" +} +``` + +#### Generate Personal Access Token (PAT) +```http +POST /api/tokens/generate +Content-Type: application/json +Authorization: Session ... + +{ + "name": "Frontend Integration Token", + "expiresAt": "2026-12-31T23:59:59" +} +``` + +--- + +### SMS Notifications + +#### Send Templated SMS (Immediate) +```http +POST /api/notifications +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "templateCode": "WELCOME_SMS", + "customerName": "John Doe", + "otpCode": "123456" +} +``` + +#### Send Templated SMS (Scheduled) +```http +POST /api/notifications +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "templateCode": "REMAINDER_SMS", + "scheduledAt": "2026-03-10T10:00:00", + "customerName": "John Doe" +} +``` + +#### Send Passthrough SMS (Raw Content) +```http +POST /api/notifications/raw +Content-Type: application/json +X-API-KEY: your_raw_pat_token + +{ + "phoneNumber": "255700000000", + "provider": "NEXT", + "content": "This is a direct message without using a template!" +} +``` + +--- + +### SMS Logs & Monitoring + +#### List Logs (Filtered by Failed) +```http +GET /api/smsLogs?filter=status:EQ:FAILED&page=0&size=10 +X-API-KEY: your_raw_pat_token +``` + +#### Retry Failed Log +```http +POST /api/smsLogs/{{log_id}}/retry +X-API-KEY: your_raw_pat_token + +--- + +### Webhook Triggers (Bulk Dispatch) + +#### Trigger Templated Bulk (Existing) +```http +POST /api/webhooks/{{connector_id}}/sms +Content-Type: application/json + +{ + "templateCode": "WELCOME_SMS", + "provider": "BEEM", + "filterQuery": { + "status": "active" + } +} +``` + +#### Trigger Passthrough Bulk (Raw Content) +```http +POST /api/webhooks/{{connector_id}}/sms +Content-Type: application/json + +{ + "content": "Urgent update: Our office will be closed tomorrow. Thank you!", + "provider": "NEXT", + "filterQuery": { + "type": "customer" + } +} +``` +``` + +--- + +### Templates + +#### Preview Template +```http +POST /api/smsTemplates/preview +Content-Type: application/json + +{ + "template": "Hello {{name}}, your balance is {{balance}}.", + "variables": { + "name": "Alice", + "balance": "5,000 TZS" + } +} +``` diff --git a/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java b/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java index acecad0..fdd4949 100644 --- a/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java +++ b/src/main/java/com/flexcodelabs/flextuma/FlextumaApplication.java @@ -8,6 +8,7 @@ @org.springframework.boot.persistence.autoconfigure.EntityScan(basePackages = "com.flexcodelabs.flextuma.core.entities") @org.springframework.data.jpa.repository.config.EnableJpaRepositories(basePackages = "com.flexcodelabs.flextuma.core.repositories") @org.springframework.data.jpa.repository.config.EnableJpaAuditing(auditorAwareRef = "auditorProvider") +@org.springframework.scheduling.annotation.EnableScheduling public class FlextumaApplication { public static void main(String[] args) { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java b/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java new file mode 100644 index 0000000..b6cc658 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/annotations/FeatureGate.java @@ -0,0 +1,40 @@ +package com.flexcodelabs.flextuma.core.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method as requiring a specific feature to be enabled for the calling + * user's organisation. If the feature is disabled for the organisation, a 403 + * Forbidden is thrown. + * + *

+ * Default behaviour: if no + * {@link com.flexcodelabs.flextuma.core.entities.feature.TenantFeature} + * record exists for the organisation + key, the feature is + * allowed. + * Only explicit {@code enabled = false} records block access. + * + *

+ * Users without an organisation (e.g. SUPER_ADMIN / system users) always bypass + * the check. + * + *

+ * Usage: + * + *

+ * {@literal @}FeatureGate("BULK_CAMPAIGN")
+ * public void sendCampaign(...) { ... }
+ * 
+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FeatureGate { + /** + * The feature key that must be enabled, e.g. {@code "BULK_CAMPAIGN"}, + * {@code "WHATSAPP_SEND"}. + */ + String value(); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java b/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java new file mode 100644 index 0000000..0e40f57 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspect.java @@ -0,0 +1,60 @@ +package com.flexcodelabs.flextuma.core.aspects; + +import com.flexcodelabs.flextuma.core.annotations.FeatureGate; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class FeatureGateAspect { + + private final CurrentUserResolver currentUserResolver; + private final TenantFeatureRepository featureRepository; + + @Before("@annotation(gate)") + public void checkFeature(FeatureGate gate) { + String featureKey = gate.value(); + + Optional userOpt = currentUserResolver.getCurrentUser(); + + if (userOpt.isEmpty()) { + return; + } + + User user = userOpt.get(); + Organisation organisation = user.getOrganisation(); + + if (organisation == null) { + return; + } + + Optional feature = featureRepository.findByOrganisationAndFeatureKey(organisation, featureKey); + + if (feature.isEmpty()) { + return; + } + + if (Boolean.FALSE.equals(feature.get().getEnabled())) { + log.warn("Feature [{}] is disabled for organisation [{}]", featureKey, organisation.getId()); + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Feature [" + featureKey + "] is not enabled for your organisation"); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java new file mode 100644 index 0000000..4363807 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Organisation.java @@ -0,0 +1,41 @@ +package com.flexcodelabs.flextuma.core.entities.auth; + +import com.flexcodelabs.flextuma.core.entities.base.NameEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "organisation") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Organisation extends NameEntity { + + public static final String PLURAL = "organisations"; + public static final String NAME_PLURAL = "Organisations"; + public static final String NAME_SINGULAR = "Organisation"; + public static final String READ = "READ_ORGANISATIONS"; + public static final String ADD = "ADD_ORGANISATIONS"; + public static final String DELETE = "DELETE_ORGANISATIONS"; + public static final String UPDATE = "UPDATE_ORGANISATIONS"; + + @NotBlank(message = "Phone number is required") + @Column(name = "phonenumber", nullable = false) + private String phoneNumber; + + @Column(nullable = true) + private String email; + + @Column(nullable = true) + private String address; + + @Column(nullable = true) + private String website; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java new file mode 100644 index 0000000..0d92a67 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/PersonalAccessToken.java @@ -0,0 +1,68 @@ +package com.flexcodelabs.flextuma.core.entities.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@Entity +@Table(name = "personalaccesstoken") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PersonalAccessToken extends BaseEntity { + + public static final String PLURAL = "tokens"; + public static final String NAME_PLURAL = "Personal Access Tokens"; + public static final String NAME_SINGULAR = "Personal Access Token"; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String token; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + private LocalDateTime lastUsedAt; + + private LocalDateTime expiresAt; + + @Transient + private String rawToken; + + @PrePersist + public void generateToken() { + if (this.token == null) { + this.rawToken = "ft_" + java.util.UUID.randomUUID().toString().replace("-", ""); + this.token = hashToken(this.rawToken); + } + if (this.getActive() == null) { + this.setActive(true); + } + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java index 18b099c..1b08af9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/Privilege.java @@ -8,7 +8,7 @@ import lombok.*; @Entity -@Table(name = "privilege", schema = "public") +@Table(name = "privilege") @Getter @Setter @NoArgsConstructor diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java index cfb68d5..9358e04 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java @@ -89,6 +89,10 @@ public class User extends BaseEntity { @JoinTable(name = "userrole", joinColumns = @JoinColumn(name = "owner", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "role", referencedColumnName = "id")) private Set roles; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation", referencedColumnName = "id", nullable = true) + private Organisation organisation; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "creator", referencedColumnName = "id", nullable = true) @CreatedBy diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java index 3352b9c..704ccbb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/connector/ConnectorConfig.java @@ -36,13 +36,14 @@ public class ConnectorConfig extends Owner { public static final String PLURAL = "connectorConfigs"; - public static final String NAME_PLURAL = "Connector Configs"; - public static final String NAME_SINGULAR = "Connector Config"; - - public static final String READ = "READ_CONNECTOR_CONFIGS"; - public static final String ADD = "ADD_CONNECTOR_CONFIGS"; - public static final String DELETE = "DELETE_CONNECTOR_CONFIGS"; - public static final String UPDATE = "UPDATE_CONNECTOR_CONFIGS"; + public static final String NAME_PLURAL = "ConnectorConfigs"; + public static final String NAME_SINGULAR = "ConnectorConfig"; + + 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, unique = true, name = "tenantid") @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java new file mode 100644 index 0000000..95c9a61 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/feature/TenantFeature.java @@ -0,0 +1,52 @@ +package com.flexcodelabs.flextuma.core.entities.feature; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "tenantfeature", uniqueConstraints = { + @UniqueConstraint(name = "unique_org_feature", columnNames = { "organisation", "featurekey" }) +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TenantFeature extends BaseEntity { + + public static final String PLURAL = "tenantFeatures"; + public static final String NAME_PLURAL = "Tenant Features"; + public static final String NAME_SINGULAR = "Tenant Feature"; + + public static final String READ = "READ_TENANT_FEATURES"; + public static final String ADD = "ADD_TENANT_FEATURES"; + public static final String DELETE = "DELETE_TENANT_FEATURES"; + public static final String UPDATE = "UPDATE_TENANT_FEATURES"; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organisation", nullable = false) + private Organisation organisation; + + @NotBlank(message = "Feature key is required") + @Column(name = "featurekey", nullable = false) + private String featureKey; + + @Column(nullable = false) + private Boolean enabled = true; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java new file mode 100644 index 0000000..6b547ff --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/Wallet.java @@ -0,0 +1,40 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "wallet") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Wallet extends Owner { + public static final String PLURAL = "wallets"; + public static final String NAME_PLURAL = "Wallets"; + public static final String NAME_SINGULAR = "Wallet"; + + 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, precision = 19, scale = 4) + private BigDecimal balance = BigDecimal.ZERO; + + @Column(nullable = false, length = 3) + private String currency = "TZS"; + + @Version + private Long version; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java new file mode 100644 index 0000000..e208c4b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransaction.java @@ -0,0 +1,49 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Entity +@Table(name = "wallettransaction") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class WalletTransaction extends BaseEntity { + public static final String PLURAL = "walletTransactions"; + public static final String NAME_PLURAL = "WalletTransactions"; + public static final String NAME_SINGULAR = "WalletTransaction"; + + 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; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "wallet", referencedColumnName = "id", nullable = false, updatable = false) + private Wallet wallet; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, updatable = false) + private TransactionType type; + + @Column(nullable = false, precision = 19, scale = 4, updatable = false) + private BigDecimal amount; + + @Column(nullable = false, updatable = false, length = 500) + private String description; + + @Column(updatable = false, length = 100) + private String reference; + + @Column(name = "balance_after", nullable = false, precision = 19, scale = 4, updatable = false) + private BigDecimal balanceAfter; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java index ef8b8fd..d21dbcb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/ListEntity.java @@ -17,13 +17,15 @@ @Setter @JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) @JsonInclude(JsonInclude.Include.NON_NULL) -public class ListEntity extends AbstractMetadataEntity { +public class ListEntity extends AbstractMetadataEntity { public static final String PLURAL = "lists"; public static final String NAME_PLURAL = "Lists"; public static final String NAME_SINGULAR = "List"; - public static final String READ = "READ_LISTS"; - public static final String ADD = "ADD_LISTS"; - public static final String DELETE = "DELETE_LISTS"; - public static final String UPDATE = "UPDATE_LISTS"; + + 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; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java index d7d218a..aacaae9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/metadata/Tag.java @@ -22,9 +22,11 @@ public class Tag extends AbstractMetadataEntity { public static final String PLURAL = "tags"; public static final String NAME_PLURAL = "Tags"; public static final String NAME_SINGULAR = "Tag"; - public static final String READ = "READ_TAGS"; - public static final String ADD = "ADD_TAGS"; - public static final String DELETE = "DELETE_TAGS"; - public static final String UPDATE = "UPDATE_TAGS"; + + 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; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java new file mode 100644 index 0000000..3f6fc22 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsCampaign.java @@ -0,0 +1,59 @@ +package com.flexcodelabs.flextuma.core.entities.sms; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "smscampaign") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties({ "hibernateLazyInitializer", "handler" }) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SmsCampaign extends Owner { + + public static final String PLURAL = "campaigns"; + public static final String NAME_PLURAL = "SmsCampaigns"; + public static final String NAME_SINGULAR = "SmsCampaign"; + + 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 name; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "template_id") + private SmsTemplate template; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SmsCampaignStatus status = SmsCampaignStatus.DRAFT; + + @Column(columnDefinition = "TEXT") + private String recipients; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connector_id", nullable = false) + private SmsConnector connector; + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java index abec510..89c697d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java @@ -17,13 +17,15 @@ @AllArgsConstructor public class SmsConnector extends Owner { - public static final String PLURAL = "smsConnectors"; - public static final String NAME_PLURAL = "SMS Connectors"; - public static final String NAME_SINGULAR = "SMS Connector"; - public static final String READ = "READ_SMS_CONNECTORS"; - public static final String ADD = "ADD_SMS_CONNECTORS"; - public static final String DELETE = "DELETE_SMS_CONNECTORS"; - public static final String UPDATE = "UPDATE_SMS_CONNECTORS"; + public static final String PLURAL = "connectors"; + public static final String NAME_PLURAL = "SmsConnectors"; + public static final String NAME_SINGULAR = "SmsConnector"; + + 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; @NotBlank(message = "Provider name is required") private String provider; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index c0841f3..06ad5e1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -3,9 +3,12 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; @@ -27,12 +30,14 @@ public class SmsLog extends Owner { public static final String PLURAL = "smsLogs"; - public static final String NAME_PLURAL = "SMS Logs"; - public static final String NAME_SINGULAR = "SMS Log"; - public static final String READ = "READ_SMS_TEMPLATES"; - public static final String ADD = "ADD_SMS_LOGS"; - public static final String DELETE = "DELETE_SMS_LOGS"; - public static final String UPDATE = "UPDATE_SMS_LOGS"; + public static final String NAME_PLURAL = "SmsLogs"; + public static final String NAME_SINGULAR = "SmsLog"; + + 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; private String recipient; @@ -40,7 +45,15 @@ public class SmsLog extends Owner { private String content; @Column(columnDefinition = "TEXT", name = "status") - private String status; + @Enumerated(EnumType.STRING) + private SmsLogStatus status; + + @Column(name = "retries", nullable = false) + private int retries = 0; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "connector", nullable = true) + private SmsConnector connector; @Column(columnDefinition = "TEXT", name = "providerresponse", nullable = true) private String providerResponse; @@ -52,4 +65,7 @@ public class SmsLog extends Owner { @JoinColumn(name = "template") private SmsTemplate template; + @Column(name = "scheduled_at", nullable = true) + private java.time.LocalDateTime scheduledAt; + } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java index f13bb81..6497d19 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java @@ -22,13 +22,14 @@ public class SmsTemplate extends Owner { public static final String PLURAL = "templates"; - public static final String NAME_PLURAL = "SMS Templates"; - public static final String NAME_SINGULAR = "SMS Template"; + public static final String NAME_PLURAL = "SmsTemplates"; + public static final String NAME_SINGULAR = "SmsTemplate"; - public static final String READ = "READ_SMS_TEMPLATES"; - public static final String ADD = "ADD_SMS_TEMPLATES"; - public static final String DELETE = "DELETE_SMS_TEMPLATES"; - public static final String UPDATE = "UPDATE_SMS_TEMPLATES"; + 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 = true, unique = false) private String code; @@ -36,10 +37,10 @@ public class SmsTemplate extends Owner { @Column(nullable = false, updatable = true) private String name; - @Column( nullable = true, updatable = true) + @Column(nullable = true, updatable = true) private String description; - @Column( columnDefinition = "TEXT", nullable = false) + @Column(columnDefinition = "TEXT", nullable = false) private String content; @Column(nullable = true, updatable = true) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java new file mode 100644 index 0000000..5b91034 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsCampaignStatus.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum SmsCampaignStatus { + DRAFT, + SCHEDULED, + PROCESSING, + COMPLETED, + CANCELLED +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java new file mode 100644 index 0000000..1bc2806 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsLogStatus.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum SmsLogStatus { + PENDING, + PROCESSING, + SENT, + FAILED, + DELIVERED +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java new file mode 100644 index 0000000..9726db4 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/TransactionType.java @@ -0,0 +1,6 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum TransactionType { + CREDIT, + DEBIT +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java new file mode 100644 index 0000000..4c09042 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java @@ -0,0 +1,32 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * Resolves the full User entity (including organisation) from the security + * context. + * The standard Spring UserDetails only stores username + authorities β€” we need + * the + * DB entity to access organisation membership and other domain fields. + */ +@Component +@RequiredArgsConstructor +public class CurrentUserResolver { + + private final UserRepository userRepository; + + public Optional getCurrentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || !(auth.getPrincipal() instanceof String username)) { + return Optional.empty(); + } + return userRepository.findByUsername(username); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java new file mode 100644 index 0000000..f52a3c1 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculator.java @@ -0,0 +1,99 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SmsSegmentCalculator { + + private int gsm7MaxLength = 160; + private int gsm7MultipartLength = 153; + private int ucs2MaxLength = 70; + private int ucs2MultipartLength = 67; + + @Value("${app.sms.segment.gsm7.max:160}") + public void setGsm7MaxLength(int gsm7MaxLength) { + this.gsm7MaxLength = gsm7MaxLength; + } + + @Value("${app.sms.segment.gsm7.multipart:153}") + public void setGsm7MultipartLength(int gsm7MultipartLength) { + this.gsm7MultipartLength = gsm7MultipartLength; + } + + @Value("${app.sms.segment.ucs2.max:70}") + public void setUcs2MaxLength(int ucs2MaxLength) { + this.ucs2MaxLength = ucs2MaxLength; + } + + @Value("${app.sms.segment.ucs2.multipart:67}") + public void setUcs2MultipartLength(int ucs2MultipartLength) { + this.ucs2MultipartLength = ucs2MultipartLength; + } + + private static final Set GSM7_CHARS = new HashSet<>(); + + static { + String gsm7Chars = "@Β£$Β₯èéùìòÇ\nØø\rΓ…Γ₯Ξ”_Ξ¦Ξ“Ξ›Ξ©Ξ Ξ¨Ξ£Ξ˜Ξž\u001BΓ†Γ¦ΓŸΓ‰ !\"#Β€%&'()*+,-./0123456789:;<=>?Β‘ABCDEFGHIJKLMNOPQRSTUVWXYZΓ„Γ–Γ‘ΓœΒ§ΒΏabcdefghijklmnopqrstuvwxyzÀâñüà^{}\\[~]|€"; + for (char c : gsm7Chars.toCharArray()) { + GSM7_CHARS.add(c); + } + } + + private static final Set GSM7_EXTENDED_CHARS = new HashSet<>(); + static { + String gsm7ExtChars = "^{}\\[~]|€"; + for (char c : gsm7ExtChars.toCharArray()) { + GSM7_EXTENDED_CHARS.add(c); + } + } + + public SmsSegmentResult calculate(String message) { + if (message == null || message.isEmpty()) { + return new SmsSegmentResult(0, true, 0, gsm7MaxLength); + } + + boolean isGsm7 = true; + int gsm7Length = 0; + + for (int i = 0; i < message.length(); i++) { + char c = message.charAt(i); + + if (!GSM7_CHARS.contains(c)) { + isGsm7 = false; + break; + } + gsm7Length += GSM7_EXTENDED_CHARS.contains(c) ? 2 : 1; + } + + int segments; + int finalLength; + int maxCapacity; + + if (isGsm7) { + finalLength = gsm7Length; + if (finalLength <= gsm7MaxLength) { + segments = 1; + maxCapacity = gsm7MaxLength; + } else { + segments = (int) Math.ceil((double) finalLength / gsm7MultipartLength); + maxCapacity = segments * gsm7MultipartLength; + } + } else { + finalLength = message.length(); + if (finalLength <= ucs2MaxLength) { + segments = 1; + maxCapacity = ucs2MaxLength; + } else { + segments = (int) Math.ceil((double) finalLength / ucs2MultipartLength); + maxCapacity = segments * ucs2MultipartLength; + } + } + + int charactersRemaining = maxCapacity - finalLength; + return new SmsSegmentResult(segments, isGsm7, finalLength, charactersRemaining); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java new file mode 100644 index 0000000..6095b64 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentResult.java @@ -0,0 +1,4 @@ +package com.flexcodelabs.flextuma.core.helpers; + +public record SmsSegmentResult(int segments, boolean isGsm7, int length, int charactersRemaining) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java new file mode 100644 index 0000000..7f8eaa5 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/TenantAwareSpecification.java @@ -0,0 +1,68 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.criteria.*; +import org.springframework.data.jpa.domain.Specification; + +import java.io.Serial; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * A JPA Specification that mirrors the Node.js getWhere pattern: + * + * - SUPER_ADMIN / ALL authority β†’ no restriction (sees everything) + * - User with an organisation β†’ sees resources created by anyone in the same + * org OR by themselves + * - User without an organisation β†’ sees only resources they created + * + * Applies only to entities that extend Owner (i.e. have a "createdBy" field). + * Entities that do NOT have createdBy (e.g. Organisation itself) are + * unaffected. + */ +public class TenantAwareSpecification implements Specification { + + @Serial + private static final long serialVersionUID = 1L; + + private static final String CREATED_BY = "createdBy"; + private static final String ORGANISATION = "organisation"; + private static final Set BYPASS_AUTHORITIES = Set.of("ALL", "SUPER_ADMIN"); + + private final transient User currentUser; + private final transient Set userAuthorities; + + public TenantAwareSpecification(User currentUser, Set userAuthorities) { + this.currentUser = currentUser; + this.userAuthorities = userAuthorities; + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { + + if (userAuthorities.stream().anyMatch(BYPASS_AUTHORITIES::contains)) { + return cb.conjunction(); + } + + try { + root.get(CREATED_BY); + } catch (IllegalArgumentException e) { + return cb.conjunction(); + } + + List predicates = new ArrayList<>(); + + predicates.add(cb.equal(root.get(CREATED_BY), currentUser)); + + Organisation organisation = currentUser.getOrganisation(); + if (organisation != null) { + Join creatorJoin = root.join(CREATED_BY, JoinType.LEFT); + predicates.add(cb.equal(creatorJoin.get(ORGANISATION), organisation)); + } + + return cb.or(predicates.toArray(new Predicate[0])); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java b/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java index d62248b..321ea56 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/interceptors/AuditorAwareImpl.java @@ -12,13 +12,15 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import org.springframework.beans.factory.ObjectProvider; + import java.util.Optional; @Component("auditorProvider") @RequiredArgsConstructor public class AuditorAwareImpl implements AuditorAware { - private final UserRepository userRepository; + private final ObjectProvider userRepositoryProvider; private final EntityManager entityManager; @Override @@ -38,7 +40,7 @@ public Optional getCurrentAuditor() { FlushModeType originalFlushMode = entityManager.getFlushMode(); try { entityManager.setFlushMode(FlushModeType.COMMIT); - return userRepository.findByIdentifier(username); + return userRepositoryProvider.getObject().findByIdentifier(username); } finally { entityManager.setFlushMode(originalFlushMode); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java new file mode 100644 index 0000000..9b5b61c --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/OrganisationRepository.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface OrganisationRepository + extends JpaRepository, JpaSpecificationExecutor { + java.util.Optional findByCode(String code); + + java.util.Optional findByActive(Boolean active); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java new file mode 100644 index 0000000..df995ff --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalAccessTokenRepository.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PersonalAccessTokenRepository extends BaseRepository, + org.springframework.data.jpa.repository.JpaSpecificationExecutor { + Optional findByToken(String token); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java new file mode 100644 index 0000000..64fc361 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java @@ -0,0 +1,23 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Repository +public interface SmsCampaignRepository extends BaseRepository, + org.springframework.data.jpa.repository.JpaSpecificationExecutor { + + @Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now") + List findDueCampaigns( + @Param("status") SmsCampaignStatus status, + @Param("now") LocalDateTime now, + Pageable pageable); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index 0d9d2be..2b1ffaf 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -1,14 +1,26 @@ package com.flexcodelabs.flextuma.core.repositories; +import java.util.List; +import java.util.Optional; import java.util.UUID; -import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; @Repository -public interface SmsLogRepository extends JpaRepository, +public interface SmsLogRepository extends BaseRepository, JpaSpecificationExecutor { + + List findTop50ByStatusOrderByCreatedAsc(SmsLogStatus status); + + @org.springframework.data.jpa.repository.Query("SELECT s FROM SmsLog s WHERE s.status = :status AND (s.scheduledAt IS NULL OR s.scheduledAt <= :now) ORDER BY s.created ASC") + List findDueMessages( + @org.springframework.data.repository.query.Param("status") SmsLogStatus status, + @org.springframework.data.repository.query.Param("now") java.time.LocalDateTime now, + org.springframework.data.domain.Pageable pageable); + + Optional findByProviderResponse(String providerResponse); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java new file mode 100644 index 0000000..d46577a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/TenantFeatureRepository.java @@ -0,0 +1,21 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface TenantFeatureRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByOrganisationAndFeatureKey(Organisation organisation, String featureKey); + + List findAllByOrganisation(Organisation organisation); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java new file mode 100644 index 0000000..6029b83 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface WalletRepository extends JpaRepository { + Optional findByCreatedBy(User user); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java new file mode 100644 index 0000000..7243bd5 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java @@ -0,0 +1,9 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.finance.WalletTransaction; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface WalletTransactionRepository extends JpaRepository { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java new file mode 100644 index 0000000..3195d31 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilter.java @@ -0,0 +1,78 @@ +package com.flexcodelabs.flextuma.core.security; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class PatAuthenticationFilter extends OncePerRequestFilter { + + private final PersonalAccessTokenRepository patRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String apiKey = request.getHeader("X-API-KEY"); + + if (apiKey != null && !apiKey.isBlank()) { + String hashedToken = hashToken(apiKey); + Optional patOpt = patRepository.findByToken(hashedToken); + + if (patOpt.isPresent()) { + PersonalAccessToken pat = patOpt.get(); + + if (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now())) { + User user = pat.getUser(); + + Set authorities = user.getRoles().stream() + .flatMap(role -> role.getPrivileges().stream()) + .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) + .collect(Collectors.toSet()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), null, authorities); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + + pat.setLastUsedAt(LocalDateTime.now()); + patRepository.save(pat); + } + } + } + + filterChain.doFilter(request, response); + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (java.security.NoSuchAlgorithmException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "SHA-256 algorithm not found", e); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index c405222..01e9818 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -10,7 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession; import org.springframework.session.web.http.CookieSerializer; import org.springframework.session.web.http.DefaultCookieSerializer; @@ -21,9 +20,12 @@ public class SecurityConfig { private final CustomSecurityExceptionHandler securityExceptionHandler; + private final PatAuthenticationFilter patAuthenticationFilter; - public SecurityConfig(CustomSecurityExceptionHandler securityExceptionHandler) { + public SecurityConfig(CustomSecurityExceptionHandler securityExceptionHandler, + PatAuthenticationFilter patAuthenticationFilter) { this.securityExceptionHandler = securityExceptionHandler; + this.patAuthenticationFilter = patAuthenticationFilter; } @Bean @@ -44,14 +46,13 @@ public CookieSerializer cookieSerializer() { public SecurityFilterChain securityFilterChain(HttpSecurity http) { try { http - .csrf(csrf -> csrf - .csrfTokenRepository( - new org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository()) - .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())) + .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()) + .addFilterBefore(patAuthenticationFilter, + org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint(securityExceptionHandler) .accessDeniedHandler(securityExceptionHandler)) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java index c18ec84..d85718f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityUtils.java @@ -26,4 +26,12 @@ public static Set getCurrentUserAuthorities() { .map(GrantedAuthority::getAuthority) .collect(Collectors.toSet()); } + + public static String getCurrentUsername() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + return null; + } + return auth.getName(); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java similarity index 82% rename from src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java rename to src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java index 3679f2b..9fe458c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/BeamSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/BeemSender.java @@ -18,17 +18,17 @@ @Slf4j @Service -public class BeamSender implements SmsSender { +public class BeemSender implements SmsSender { private final RestTemplate restTemplate; - public BeamSender(RestTemplate restTemplate) { + public BeemSender(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @Override public String getProvider() { - return "BEAM"; + return "BEEM"; } @Override @@ -44,7 +44,7 @@ public String sendSms(SmsConnector config, String to, String message) { String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes()); headers.set("Authorization", "Basic " + encodedAuth); - BeamSmsRequest requestBody = new BeamSmsRequest(); + BeemSmsRequest requestBody = new BeemSmsRequest(); requestBody.setSourceAddr(config.getSenderId()); requestBody.setMessage(message); requestBody.setScheduleTime(""); @@ -55,31 +55,31 @@ public String sendSms(SmsConnector config, String to, String message) { recipient.setRecipientId("1"); requestBody.setRecipients(Collections.singletonList(recipient)); - HttpEntity entity = new HttpEntity<>(requestBody, headers); + HttpEntity entity = new HttpEntity<>(requestBody, headers); - ResponseEntity response = restTemplate.postForEntity( + ResponseEntity response = restTemplate.postForEntity( config.getUrl(), entity, - BeamSmsResponse.class); + BeemSmsResponse.class); - BeamSmsResponse responseBody = response.getBody(); + BeemSmsResponse responseBody = response.getBody(); if (responseBody != null && !responseBody.isValid()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Beem API Error: " + responseBody.getMessage()); } - log.info("BEAM: SMS sent successfully to {}", to); + log.info("BEEM: SMS sent successfully to {}", to); return responseBody != null ? responseBody.getMessage() : "SUCCESS"; } catch (Exception e) { - log.error("BEAM Error: {}", e.getMessage()); + log.error("BEEM Error: {}", e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to send via Beem: " + e.getMessage()); } } @Data - static class BeamSmsRequest { + static class BeemSmsRequest { @JsonProperty("source_addr") private String sourceAddr; @@ -103,7 +103,7 @@ static class Recipient { @Data @NoArgsConstructor @AllArgsConstructor - static class BeamSmsResponse { + static class BeemSmsResponse { private boolean valid; private String message; private int code; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java index 56e23e2..1482d44 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/NextSmsSender.java @@ -1,6 +1,16 @@ package com.flexcodelabs.flextuma.core.senders; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Collections; +import java.util.Base64; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.services.SmsSender; @@ -10,6 +20,13 @@ @Slf4j @Service public class NextSmsSender implements SmsSender { + + private final RestTemplate restTemplate; + + public NextSmsSender(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + @Override public String getProvider() { return "NEXT"; @@ -17,9 +34,71 @@ public String getProvider() { @Override public String sendSms(SmsConnector config, String to, String message) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + String auth = config.getKey() + ":" + config.getSecret(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes()); + headers.set("Authorization", "Basic " + encodedAuth); + + NextSmsRequest requestBody = new NextSmsRequest(); + requestBody.setFrom(config.getSenderId()); + requestBody.setTo(to); + requestBody.setText(message); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity( + config.getUrl(), + entity, + NextSmsResponse.class); + + NextSmsResponse responseBody = response.getBody(); + + if (responseBody != null && responseBody.getMessages() != null && !responseBody.getMessages().isEmpty()) { + NextSmsResponse.MessageDetail detail = responseBody.getMessages().get(0); + if (detail.getStatus() != null && detail.getStatus().getGroupId() > 2) { + log.warn("NextSMS: Potential error in delivery: {}", detail.getStatus().getName()); + } + return detail.getMessageId(); + } + + return "SUCCESS"; + + } catch (Exception e) { + log.error("NextSMS Error: {}", e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Failed to send via NextSMS: " + e.getMessage()); + } + } + + @lombok.Data + static class NextSmsRequest { + private String from; + private String to; + private String text; + } + + @lombok.Data + static class NextSmsResponse { + private List messages; - log.info("NEXT SMS SENDER: Sending SMS to {} with message: {}", to, message); + @lombok.Data + static class MessageDetail { + private String to; + private Status detail; + private Status status; + private String messageId; + } - return "Message sent to " + to + " with content: " + message; + @lombok.Data + static class Status { + private int groupId; + private String groupName; + private int id; + private String name; + private String description; + } } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 80557c3..b75ec45 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.helpers.GenericSpecification; +import com.flexcodelabs.flextuma.core.helpers.TenantAwareSpecification; import com.flexcodelabs.flextuma.core.security.SecurityUtils; import jakarta.persistence.EntityManager; @@ -24,6 +26,13 @@ public abstract class BaseService { @PersistenceContext protected EntityManager entityManager; + private CurrentUserResolver currentUserResolver; + + @org.springframework.beans.factory.annotation.Autowired + public void setCurrentUserResolver(CurrentUserResolver currentUserResolver) { + this.currentUserResolver = currentUserResolver; + } + protected abstract JpaRepository getRepository(); protected abstract String getReadPermission(); @@ -42,12 +51,16 @@ public abstract class BaseService { protected abstract JpaSpecificationExecutor getRepositoryAsExecutor(); + protected boolean isAdminEntity() { + return false; + } + protected void checkPermission(String requiredPermission) { Set authorities = SecurityUtils.getCurrentUserAuthorities(); - boolean isAuthorized = authorities.contains("ALL") || - authorities.contains("SUPER_ADMIN") || - authorities.contains(requiredPermission); + boolean isAuthorized = authorities.contains("SUPER_ADMIN") || + authorities.contains(requiredPermission) || + (!isAdminEntity() && authorities.contains("ALL")); if (!isAuthorized) { throw new AccessDeniedException("You have no permission to access " + getEntityPlural()); @@ -58,7 +71,7 @@ protected void checkPermission(String requiredPermission) { public Pagination findAllPaginated(Pageable pageable, List filter, String fields) { checkPermission(getReadPermission()); - Specification spec = (root, query, cb) -> cb.conjunction(); + Specification spec = buildTenantSpec(); if (filter != null && !filter.isEmpty()) { for (String filterStr : filter) { @@ -70,6 +83,14 @@ public Pagination findAllPaginated(Pageable pageable, List filter, St return buildPaginatedResponse(resultPage, pageable); } + @SuppressWarnings("unchecked") + private Specification buildTenantSpec() { + return currentUserResolver.getCurrentUser() + .map(user -> (Specification) new TenantAwareSpecification<>(user, + SecurityUtils.getCurrentUserAuthorities())) + .orElse((root, query, cb) -> cb.conjunction()); + } + private Pagination buildPaginatedResponse(Page resultPage, Pageable pageable) { return Pagination.builder() .page(pageable.getPageNumber() + 1) @@ -82,7 +103,8 @@ private Pagination buildPaginatedResponse(Page resultPage, Pageable pageab @Transactional(readOnly = true) public List findAll() { checkPermission(getReadPermission()); - return getRepository().findAll(); + Specification spec = buildTenantSpec(); + return getRepositoryAsExecutor().findAll(spec); } @Transactional(readOnly = true) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java index 9012c8e..459f9e3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/DataSeederService.java @@ -14,52 +14,52 @@ @RequiredArgsConstructor public class DataSeederService { - private final JdbcTemplate jdbcTemplate; - private final PasswordEncoder passwordEncoder; + private final JdbcTemplate jdbcTemplate; + private final PasswordEncoder passwordEncoder; - @Transactional - public void seedSystemData() { - UUID privId = UUID.fromString("5269df21-c8a0-4776-bd89-1015521bc19d"); - jdbcTemplate.update( - "INSERT INTO privilege (id, name, value, system, active, created, updated) " + - "VALUES (?, 'Super Admin', 'SUPER_ADMIN', true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - privId); + @Transactional + public void seedSystemData() { + UUID privId = UUID.fromString("5269df21-c8a0-4776-bd89-1015521bc19d"); + jdbcTemplate.update( + "INSERT INTO privilege (id, name, value, system, active, created, updated) " + + "VALUES (?, 'Super Admin', 'SUPER_ADMIN', true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + privId); - UUID roleId = UUID.fromString("6269df23-f8a0-4776-bd89-3015521bc19d"); - jdbcTemplate.update( - "INSERT INTO role (id, name, system, active, created, updated) " + - "VALUES (?, 'Super Admin', true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - roleId); + UUID roleId = UUID.fromString("6269df23-f8a0-4776-bd89-3015521bc19d"); + jdbcTemplate.update( + "INSERT INTO role (id, name, system, active, created, updated) " + + "VALUES (?, 'Super Admin', true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + roleId); - jdbcTemplate.update( - "INSERT INTO userprivilege (role, privilege) VALUES (?, ?) " + - "ON CONFLICT DO NOTHING", - roleId, privId); + jdbcTemplate.update( + "INSERT INTO userprivilege (role, privilege) VALUES (?, ?) " + + "ON CONFLICT DO NOTHING", + roleId, privId); - seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId); + seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId); - seedUser(privId, "SYSTEM", "system@flextuma.com", "system_secret_key", roleId); + seedUser(privId, "SYSTEM", "system@flextuma.com", "system_secret_key", roleId); - log.info("βœ…βœ…βœ… System seeding via JDBC completed successfully. βœ…βœ…βœ…"); - } + log.info("βœ…βœ…βœ… System seeding via JDBC completed successfully. βœ…βœ…βœ…"); + } - private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) { - String hashedPass = passwordEncoder.encode(pass); + private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) { + String hashedPass = passwordEncoder.encode(pass); - jdbcTemplate.update( - "INSERT INTO \"user\" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) " - + - "VALUES (?, ?, ?, ?, ?, ?, 'SYSTEM', true, true, true, NOW(), NOW()) " + - "ON CONFLICT (id) DO NOTHING", - userId, username, username.toUpperCase(), email, - username.equals("SYSTEM") ? "0000000000" : "123456789", - hashedPass); + jdbcTemplate.update( + "INSERT INTO \"user\" (id, username, name, email, phonenumber, password, type, active, verified, system, created, updated) " + + + "VALUES (?, ?, ?, ?, ?, ?, 'SYSTEM', true, true, true, NOW(), NOW()) " + + "ON CONFLICT (id) DO NOTHING", + userId, username, username.toUpperCase(), email, + username.equals("SYSTEM") ? "0000000000" : "123456789", + hashedPass); - jdbcTemplate.update( - "INSERT INTO userrole (owner, role) VALUES (?, ?) " + - "ON CONFLICT DO NOTHING", - userId, roleId); - } + jdbcTemplate.update( + "INSERT INTO userrole (owner, role) VALUES (?, ?) " + + "ON CONFLICT DO NOTHING", + userId, roleId); + } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java new file mode 100644 index 0000000..da1b576 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/RateLimiterService.java @@ -0,0 +1,43 @@ +package com.flexcodelabs.flextuma.core.services; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class RateLimiterService { + + private final Map buckets = new ConcurrentHashMap<>(); + + private Bucket createNewBucket(UUID tenantId) { + Bandwidth limit = Bandwidth.builder() + .capacity(10) + .refillGreedy(10, Duration.ofSeconds(1)) + .build(); + return Bucket.builder().addLimit(limit).build(); + } + + public void checkRateLimit(UUID tenantId) { + if (tenantId == null) { + return; + } + + Bucket bucket = buckets.computeIfAbsent(tenantId, this::createNewBucket); + + if (!bucket.tryConsume(1)) { + log.warn("Rate limit exceeded for tenant/user {}", tenantId); + throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, + "Rate limit exceeded. Please try again later."); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java new file mode 100644 index 0000000..f3a37bf --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/BeemDlrParser.java @@ -0,0 +1,36 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +@Component +public class BeemDlrParser implements DlrParser { + + private static final Set DELIVERED_STATUSES = Set.of("delivered", "sent"); + private static final Set FAILED_STATUSES = Set.of("failed", "expired", "rejected", "aborted", "undelivered", + "cancelled", "deleted"); + + @Override + public String getProvider() { + return "BEEM"; + } + + @Override + public DlrResult parse(Map payload) { + String messageId = String.valueOf(payload.getOrDefault("messageID", "")); + String rawStatus = String.valueOf(payload.getOrDefault("status", "")).toLowerCase(); + + SmsLogStatus status = null; + if (DELIVERED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.SENT; + } else if (FAILED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.FAILED; + } + + return new DlrResult(messageId, status, rawStatus); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java new file mode 100644 index 0000000..e40a7e1 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrParser.java @@ -0,0 +1,29 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; + +/** + * Parses a raw DLR (Delivery Report) payload from an SMS provider into a + * normalised {@link DlrResult}. + * + *

+ * Each provider sends a different JSON shape β€” implement this interface + * once per provider, annotate with {@code @Component}, and the + * {@link com.flexcodelabs.flextuma.modules.webhook.controllers.SmsWebhookController} + * will automatically pick it up. + */ +public interface DlrParser { + + /** + * Provider key, must match {@code SmsConnector.provider} (case-insensitive). + */ + String getProvider(); + + /** + * Parses the raw payload map into a normalised result. + * + * @param payload the deserialized JSON body from the provider's DLR callback + * @return a {@link DlrResult} with the message ID and normalised status + */ + DlrResult parse(Map payload); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java new file mode 100644 index 0000000..f9d7de9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/DlrResult.java @@ -0,0 +1,14 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +/** + * Normalised result from a DLR payload parse. + * + * @param messageId the provider-assigned message identifier (used to locate the + * SmsLog) + * @param status the mapped internal status + * @param rawStatus the raw status string from the provider (stored for audit) + */ +public record DlrResult(String messageId, SmsLogStatus status, String rawStatus) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java new file mode 100644 index 0000000..4db4fa0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/webhooks/NextSmsDlrParser.java @@ -0,0 +1,38 @@ +package com.flexcodelabs.flextuma.core.webhooks; + +import java.util.Map; +import java.util.Set; + +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; + +@Component +public class NextSmsDlrParser implements DlrParser { + + private static final Set DELIVERED_STATUSES = Set.of("delivrd", "delivered", "sent"); + private static final Set FAILED_STATUSES = Set.of("failed", "undeliv", "rejectd", "expired"); + + @Override + public String getProvider() { + return "NEXT"; + } + + @Override + public DlrResult parse(Map payload) { + String messageId = String.valueOf( + payload.getOrDefault("message_id", + payload.getOrDefault("messageId", ""))); + + String rawStatus = String.valueOf(payload.getOrDefault("status", "")).toLowerCase(); + + SmsLogStatus status = null; + if (DELIVERED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.SENT; + } else if (FAILED_STATUSES.contains(rawStatus)) { + status = SmsLogStatus.FAILED; + } + + return new DlrResult(messageId, status, rawStatus); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java new file mode 100644 index 0000000..24e27e7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/OrganisationController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.modules.auth.services.OrganisationService; + +@RestController +@RequestMapping("/api/" + Organisation.PLURAL) +public class OrganisationController extends BaseController { + public OrganisationController(OrganisationService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java new file mode 100644 index 0000000..dc9388e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PersonalAccessTokenController.java @@ -0,0 +1,18 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.modules.auth.services.PersonalAccessTokenService; + +@RestController +@RequestMapping("/api/" + PersonalAccessToken.PLURAL) +public class PersonalAccessTokenController extends BaseController { + + public PersonalAccessTokenController(PersonalAccessTokenService service) { + super(service); + } + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java deleted file mode 100644 index 8c5f8ad..0000000 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeContoller.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.flexcodelabs.flextuma.modules.auth.controllers; - -public class PrivilegeContoller { - private PrivilegeContoller() { - - } -} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java new file mode 100644 index 0000000..46f0c23 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PrivilegeController.java @@ -0,0 +1,15 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.web.bind.annotation.*; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.auth.Privilege; +import com.flexcodelabs.flextuma.modules.auth.services.PrivilegeService; + +@RestController +@RequestMapping("/api/" + Privilege.PLURAL) +public class PrivilegeController extends BaseController { + public PrivilegeController(PrivilegeService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java new file mode 100644 index 0000000..a93057e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/OrganisationService.java @@ -0,0 +1,75 @@ +package com.flexcodelabs.flextuma.modules.auth.services; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.repositories.OrganisationRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OrganisationService extends BaseService { + + private final OrganisationRepository repository; + + @Override + protected boolean isAdminEntity() { + return true; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return Organisation.READ; + } + + @Override + protected String getAddPermission() { + return Organisation.ADD; + } + + @Override + protected String getUpdatePermission() { + return Organisation.UPDATE; + } + + @Override + protected String getDeletePermission() { + return Organisation.DELETE; + } + + @Override + public String getEntityPlural() { + return Organisation.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return Organisation.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return Organisation.PLURAL; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void validateDelete(Organisation entity) { + if (Boolean.TRUE.equals(entity.getActive())) { + throw new IllegalStateException("You cannot delete an active organisation"); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java new file mode 100644 index 0000000..4532e17 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PersonalAccessTokenService.java @@ -0,0 +1,81 @@ +package com.flexcodelabs.flextuma.modules.auth.services; + +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +@Service +public class PersonalAccessTokenService extends BaseService { + + private final PersonalAccessTokenRepository repository; + private final UserRepository userRepository; + + public PersonalAccessTokenService(PersonalAccessTokenRepository repository, UserRepository userRepository) { + super(); + this.repository = repository; + this.userRepository = userRepository; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return "ALL"; + } + + @Override + protected String getAddPermission() { + return "ALL"; + } + + @Override + protected String getUpdatePermission() { + return "ALL"; + } + + @Override + protected String getDeletePermission() { + return "ALL"; + } + + @Override + public String getEntityPlural() { + return PersonalAccessToken.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return PersonalAccessToken.PLURAL; + } + + @Override + protected String getEntitySingular() { + return PersonalAccessToken.NAME_SINGULAR; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void onPreSave(PersonalAccessToken entity) { + if (entity.getUser() == null) { + String currentUsername = com.flexcodelabs.flextuma.core.security.SecurityUtils.getCurrentUsername(); + if (currentUsername != null) { + userRepository.findByUsername(currentUsername).ifPresent(entity::setUser); + } + } + } + +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java index 6e9312b..4d2313d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/PrivilegeService.java @@ -1,7 +1,77 @@ package com.flexcodelabs.flextuma.modules.auth.services; -public class PrivilegeService { - private PrivilegeService() { +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import com.flexcodelabs.flextuma.core.entities.auth.Privilege; +import com.flexcodelabs.flextuma.core.repositories.PrivilegeRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PrivilegeService extends BaseService { + + private final PrivilegeRepository repository; + + @Override + protected boolean isAdminEntity() { + return true; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return Privilege.READ; + } + + @Override + protected String getAddPermission() { + return Privilege.ADD; + } + + @Override + protected String getUpdatePermission() { + return Privilege.UPDATE; + } + + @Override + protected String getDeletePermission() { + return Privilege.DELETE; + } + + @Override + public String getEntityPlural() { + return Privilege.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return Privilege.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return Privilege.PLURAL; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void validateDelete(Privilege entity) { + if (Boolean.TRUE.equals(entity.getActive())) { + throw new IllegalStateException("You cannot delete an active privilege"); + } } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java index 2601c37..4b011ca 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/RoleService.java @@ -18,6 +18,11 @@ public class RoleService extends BaseService { private final RoleRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; @@ -65,7 +70,7 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Override protected void validateDelete(Role role) { - if (role.getSystem()) { + if (Boolean.TRUE.equals(role.getSystem())) { throw new IllegalStateException("System roles cannot be deleted"); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index 0e157a6..d43b7c4 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -20,6 +20,11 @@ public class UserService extends BaseService { private final UserRepository repository; + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected JpaRepository getRepository() { return repository; @@ -67,14 +72,14 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Override protected void validateDelete(User user) { - if (user.getSystem()) { + if (Boolean.TRUE.equals(user.getSystem())) { throw new IllegalStateException("System users cannot be deleted"); } } public User login(String username, String password) { User user = repository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Invalid username or password")); if (!user.validatePassword(password)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java index 387b647..f550aee 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorService.java @@ -12,6 +12,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.HashMap; import java.util.List; @@ -23,10 +27,13 @@ public class DataHydratorService { private final ConnectorConfigRepository repository; private final RestClient restClient; + private final ObjectMapper objectMapper; - public DataHydratorService(ConnectorConfigRepository repository, RestClient.Builder restClientBuilder) { + public DataHydratorService(ConnectorConfigRepository repository, RestClient.Builder restClientBuilder, + ObjectMapper objectMapper) { this.repository = repository; this.restClient = restClientBuilder.build(); + this.objectMapper = objectMapper; } public Map getMemberData(String tenantId, String memberId) { @@ -49,6 +56,50 @@ public Map getMemberData(String tenantId, String memberId) { } } + public List> getRecipients(String tenantId, Map filterQuery) { + ConnectorConfig config = repository.findByTenantId(tenantId) + .orElseThrow(() -> new RuntimeException("Connector not configured for tenant: " + tenantId)); + + if (config.getSearch() == null || config.getSearch().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Connector does not have a search endpoint configured"); + } + + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(config.getUrl() + config.getSearch()); + if (filterQuery != null) { + filterQuery.forEach(uriBuilder::queryParam); + } + + try { + String rawJsonResponse = restClient.get() + .uri(uriBuilder.build().toUri()) + .headers(h -> applyAuthentication(h, config)) + .retrieve() + .body(String.class); + + JsonNode rootNode = objectMapper.readTree(rawJsonResponse); + + if (!rootNode.isArray()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Expected JSON array from external search endpoint"); + } + + java.util.List> recipients = new java.util.ArrayList<>(); + for (JsonNode node : rootNode) { + // Apply mappings to each array element individually + Map mappedItem = applyMappings(node.toString(), config.getMappings()); + recipients.add(mappedItem); + } + return recipients; + + } catch (ResponseStatusException e) { + throw e; + } catch (Exception e) { + log.error("Failed to fetch recipients for tenant {}: {}", tenantId, e.getMessage()); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "External API call failed"); + } + } + private Map applyMappings(String json, List mappings) { Map hydratedData = new HashMap<>(); @@ -75,7 +126,7 @@ private void applyAuthentication(HttpHeaders headers, ConnectorConfig config) { case API_KEY -> headers.set("X-API-KEY", config.getToken()); case BASIC -> headers.setBasicAuth(config.getUsername(), config.getPassword()); case NONE -> { - // No auth needed + // Intentionally empty: no authentication required } default -> throw new IllegalArgumentException("Unsupported auth type: " + config.getAuthType()); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java b/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java new file mode 100644 index 0000000..6a43cf9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureController.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.modules.feature.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.modules.feature.services.TenantFeatureService; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + TenantFeature.PLURAL) +public class TenantFeatureController extends BaseController { + + public TenantFeatureController(TenantFeatureService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java new file mode 100644 index 0000000..a77c4d6 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/feature/services/TenantFeatureService.java @@ -0,0 +1,70 @@ +package com.flexcodelabs.flextuma.modules.feature.services; + +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TenantFeatureService extends BaseService { + + private final TenantFeatureRepository repository; + + @Override + protected boolean isAdminEntity() { + return true; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected String getReadPermission() { + return TenantFeature.READ; + } + + @Override + protected String getAddPermission() { + return TenantFeature.ADD; + } + + @Override + protected String getUpdatePermission() { + return TenantFeature.UPDATE; + } + + @Override + protected String getDeletePermission() { + return TenantFeature.DELETE; + } + + @Override + public String getEntityPlural() { + return TenantFeature.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return TenantFeature.PLURAL; + } + + @Override + protected String getEntitySingular() { + return TenantFeature.NAME_SINGULAR; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java new file mode 100644 index 0000000..75a12b0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java @@ -0,0 +1,86 @@ +package com.flexcodelabs.flextuma.modules.finance.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import com.flexcodelabs.flextuma.core.entities.finance.WalletTransaction; +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import com.flexcodelabs.flextuma.core.repositories.WalletRepository; +import com.flexcodelabs.flextuma.core.repositories.WalletTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class WalletService { + + private final WalletRepository walletRepository; + private final WalletTransactionRepository transactionRepository; + + public Wallet getOrCreateWallet(User user) { + Optional optionalWallet = walletRepository.findByCreatedBy(user); + if (optionalWallet.isPresent()) { + return optionalWallet.get(); + } + + Wallet newWallet = new Wallet(); + newWallet.setBalance(BigDecimal.ZERO); + newWallet.setCurrency("TZS"); + newWallet.setCreatedBy(user); + + return walletRepository.save(newWallet); + } + + @Transactional + public WalletTransaction debit(User user, BigDecimal amount, String description, String reference) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Debit amount must be positive"); + } + + Wallet wallet = getOrCreateWallet(user); + + if (wallet.getBalance().compareTo(amount) < 0) { + throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "Insufficient wallet balance"); + } + + wallet.setBalance(wallet.getBalance().subtract(amount)); + Wallet savedWallet = walletRepository.save(wallet); + + WalletTransaction transaction = new WalletTransaction(); + transaction.setWallet(savedWallet); + transaction.setType(TransactionType.DEBIT); + transaction.setAmount(amount); + transaction.setDescription(description); + transaction.setReference(reference); + transaction.setBalanceAfter(savedWallet.getBalance()); + + return transactionRepository.save(transaction); + } + + @Transactional + public WalletTransaction credit(User user, BigDecimal amount, String description, String reference) { + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Credit amount must be positive"); + } + + Wallet wallet = getOrCreateWallet(user); + + wallet.setBalance(wallet.getBalance().add(amount)); + Wallet savedWallet = walletRepository.save(wallet); + + WalletTransaction transaction = new WalletTransaction(); + transaction.setWallet(savedWallet); + transaction.setType(TransactionType.CREDIT); + transaction.setAmount(amount); + transaction.setDescription(description); + transaction.setReference(reference); + transaction.setBalanceAfter(savedWallet.getBalance()); + + return transactionRepository.save(transaction); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index 1569c8e..12abd02 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; import java.util.Map; @@ -16,13 +17,22 @@ public class NotificationController { private final NotificationService notificationService; @PostMapping("") - public ResponseEntity> sendSms( - + public ResponseEntity send( @RequestBody Map variables, java.security.Principal principal) { - notificationService.sendTemplatedSms(variables, principal.getName()); + SmsLog log = notificationService.queueTemplatedSms(variables, principal.getName()); + + return ResponseEntity.ok(log); + } + + @PostMapping("/raw") + public ResponseEntity sendRaw( + @RequestBody Map payload, + java.security.Principal principal) { + + SmsLog log = notificationService.queueRawSms(payload, principal.getName()); - return ResponseEntity.ok(Map.of("message", "SMS request queued successfully")); + return ResponseEntity.ok(log); } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java new file mode 100644 index 0000000..a5c51d2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java @@ -0,0 +1,107 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CampaignDispatchWorker { + + private final SmsCampaignRepository campaignRepository; + private final SmsLogRepository logRepository; + private final WalletService walletService; + private final SmsSegmentCalculator segmentCalculator; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void processCampaigns() { + List dueCampaigns = campaignRepository.findDueCampaigns( + SmsCampaignStatus.SCHEDULED, + LocalDateTime.now(), + PageRequest.of(0, 10)); + + if (dueCampaigns.isEmpty()) { + return; + } + + log.info("CampaignDispatchWorker: Processing {} scheduled campaign(s)", dueCampaigns.size()); + + for (SmsCampaign campaign : dueCampaigns) { + processSingleCampaign(campaign); + } + } + + private void processSingleCampaign(SmsCampaign campaign) { + try { + campaign.setStatus(SmsCampaignStatus.PROCESSING); + campaignRepository.save(campaign); + + String recipientsStr = campaign.getRecipients(); + if (recipientsStr == null || recipientsStr.isBlank()) { + campaign.setStatus(SmsCampaignStatus.COMPLETED); + campaignRepository.save(campaign); + return; + } + + String[] recipients = recipientsStr.split(","); + log.info("Processing campaign [{}] for {} recipients", campaign.getName(), recipients.length); + + SmsSegmentResult segmentResult = segmentCalculator.calculate(campaign.getContent()); + BigDecimal costPerSms = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + + for (String recipient : recipients) { + dispatchToRecipient(campaign, recipient.trim(), costPerSms); + } + + campaign.setStatus(SmsCampaignStatus.COMPLETED); + campaignRepository.save(campaign); + log.info("Campaign [{}] processing completed successfully", campaign.getName()); + + } catch (Exception e) { + log.error("Error processing campaign [{}]: {}", campaign.getName(), e.getMessage()); + } + } + + private void dispatchToRecipient(SmsCampaign campaign, String recipient, BigDecimal cost) { + if (recipient.isEmpty()) + return; + + SmsLog smsLog = new SmsLog(); + smsLog.setRecipient(recipient); + smsLog.setContent(campaign.getContent()); + smsLog.setTemplate(campaign.getTemplate()); + smsLog.setConnector(campaign.getConnector()); + smsLog.setStatus(SmsLogStatus.PENDING); + smsLog.setCreatedBy(campaign.getCreatedBy()); + + try { + walletService.debit(campaign.getCreatedBy(), cost, "Campaign SMS to " + recipient, null); + logRepository.save(smsLog); + } catch (Exception e) { + log.error("Failed to debit wallet for campaign [{}] recipient [{}]: {}", campaign.getName(), + recipient, e.getMessage()); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index b7138bc..812bbbd 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -1,24 +1,31 @@ package com.flexcodelabs.flextuma.modules.notification.services; -import java.util.List; import java.util.Map; import java.util.Optional; import org.springframework.http.HttpStatus; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; -import com.flexcodelabs.flextuma.core.services.SmsSender; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import com.flexcodelabs.flextuma.core.services.RateLimiterService; +import java.util.UUID; + +import org.springframework.beans.factory.annotation.Value; +import java.math.BigDecimal; import lombok.RequiredArgsConstructor; @@ -26,63 +33,100 @@ @RequiredArgsConstructor public class NotificationService { - private final SmsTemplateRepository templateRepository; - private final SmsLogRepository logRepository; - private final UserRepository userRepository; - private final SmsConnectorRepository connectorRepository; - private final List smsSenders; + private final SmsTemplateRepository templateRepository; + private final SmsLogRepository logRepository; + private final UserRepository userRepository; + private final SmsConnectorRepository connectorRepository; + private final WalletService walletService; + private final RateLimiterService rateLimiterService; + private final SmsSegmentCalculator segmentCalculator; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; + + @Transactional + public SmsLog queueTemplatedSms(Map placeholders, String username) { + User currentUser = getUser(username); + checkRateLimit(currentUser); + + String providerValue = getRequiredField(placeholders, "provider"); + String templateCode = getRequiredField(placeholders, "templateCode"); + String phoneNumber = getRequiredField(placeholders, "phoneNumber"); + + SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Template not found or you don't have access to it")); + + SmsConnector connector = getConnector(currentUser, providerValue); + + String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); + + return processAndSaveSms(currentUser, connector, phoneNumber, finalMessage, template, placeholders); + } + + @Transactional + public SmsLog queueRawSms(Map payload, String username) { + User currentUser = getUser(username); + checkRateLimit(currentUser); - @Async - public void sendTemplatedSms(Map placeholders, String username) { + String providerValue = getRequiredField(payload, "provider"); + String content = getRequiredField(payload, "content"); + String phoneNumber = getRequiredField(payload, "phoneNumber"); + + SmsConnector connector = getConnector(currentUser, providerValue); + + return processAndSaveSms(currentUser, connector, phoneNumber, content, null, payload); + } + + private User getUser(String username) { + if (username == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); + } + return userRepository.findByUsername(username) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, + "User not found")); + } + + private void checkRateLimit(User user) { + UUID tenantId = user.getOrganisation() != null ? user.getOrganisation().getId() : user.getId(); + rateLimiterService.checkRateLimit(tenantId); + } + + private String getRequiredField(Map data, String key) { + return Optional.ofNullable(data.get(key)) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + key + " is missing")); + } - if (username == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); + private SmsConnector getConnector(User user, String provider) { + return connectorRepository.findByCreatedByAndProviderAndActiveTrue(user, provider) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, + "No active SMS connector found for provider [" + provider + "]")); } - User currentUser = userRepository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")); - - String providerValue = Optional.ofNullable(placeholders.get("provider")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "SMS provider is missing")); - - String templateCode = Optional.ofNullable(placeholders.get("templateCode")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Template is missing")); - - String phoneNumber = Optional.ofNullable(placeholders.get("phoneNumber")) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Phone number is missing")); - - SmsSender activeSender = smsSenders.stream() - .filter(s -> s.getProvider().equalsIgnoreCase(providerValue)) - .findFirst() - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "No SMS service implementation found for provider " + "[" + providerValue + "]")); - - SmsTemplate template = templateRepository.findByCreatedByAndCode(currentUser, templateCode) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, - "Template not found or you don't have access to it")); - - SmsConnector connector = connectorRepository - .findByCreatedByAndProviderAndActiveTrue(currentUser, providerValue) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, - "No active SMS connector found for provider [" + providerValue + "]")); - - String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); - - SmsLog log = new SmsLog(); - log.setRecipient(phoneNumber); - log.setContent(finalMessage); - log.setTemplate(template); - log.setStatus("PENDING"); - log = logRepository.save(log); - - try { - String providerId = activeSender.sendSms(connector, phoneNumber, finalMessage); - log.setStatus("SENT"); - log.setProviderResponse(providerId); - } catch (Exception e) { - log.setStatus("FAILED"); - log.setError(e.getMessage()); + private SmsLog processAndSaveSms(User user, SmsConnector connector, String phoneNumber, String content, + SmsTemplate template, Map metadata) { + SmsSegmentResult segmentResult = segmentCalculator.calculate(content); + BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + + walletService.debit(user, cost, "SMS send to " + phoneNumber, null); + + SmsLog log = new SmsLog(); + log.setRecipient(phoneNumber); + log.setContent(content); + log.setTemplate(template); + log.setConnector(connector); + log.setStatus(SmsLogStatus.PENDING); + log.setCreatedBy(user); + + if (metadata.containsKey("scheduledAt")) { + try { + log.setScheduledAt(java.time.LocalDateTime.parse(metadata.get("scheduledAt"))); + } catch (Exception e) { + // Ignore invalid date format and fallback to no-scheduling + } + } + + return logRepository.save(log); } - logRepository.save(log); - } } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java new file mode 100644 index 0000000..4051071 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -0,0 +1,98 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.SmsSender; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsDispatchWorker { + + private static final int MAX_RETRIES = 3; + + private final SmsLogRepository logRepository; + private final List smsSenders; + + @Scheduled(fixedDelay = 5000) + @Transactional + public void dispatch() { + List pending = logRepository.findDueMessages( + SmsLogStatus.PENDING, + java.time.LocalDateTime.now(), + org.springframework.data.domain.PageRequest.of(0, 50)); + + if (pending.isEmpty()) { + return; + } + + log.debug("SmsDispatchWorker: picking up {} PENDING log(s)", pending.size()); + + for (SmsLog smsLog : pending) { + markProcessing(smsLog); + send(smsLog); + } + } + + private void markProcessing(SmsLog smsLog) { + smsLog.setStatus(SmsLogStatus.PROCESSING); + logRepository.save(smsLog); + } + + private void send(SmsLog smsLog) { + SmsConnector connector = smsLog.getConnector(); + + if (connector == null) { + log.error("SmsLog [{}] has no connector β€” marking FAILED", smsLog.getId()); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setError("No connector associated with this log"); + logRepository.save(smsLog); + return; + } + + SmsSender sender = smsSenders.stream() + .filter(s -> s.getProvider().equalsIgnoreCase(connector.getProvider())) + .findFirst() + .orElse(null); + + if (sender == null) { + log.error("No SmsSender implementation found for provider [{}]", connector.getProvider()); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setError("No sender implementation for provider: " + connector.getProvider()); + logRepository.save(smsLog); + return; + } + + try { + String providerResponse = sender.sendSms(connector, smsLog.getRecipient(), smsLog.getContent()); + smsLog.setStatus(SmsLogStatus.SENT); + smsLog.setProviderResponse(providerResponse); + log.debug("SmsLog [{}] sent successfully via [{}]", smsLog.getId(), connector.getProvider()); + } catch (Exception e) { + int retries = smsLog.getRetries() + 1; + smsLog.setRetries(retries); + smsLog.setError(e.getMessage()); + + if (retries >= MAX_RETRIES) { + smsLog.setStatus(SmsLogStatus.FAILED); + log.warn("SmsLog [{}] FAILED after {} retries: {}", smsLog.getId(), retries, e.getMessage()); + } else { + smsLog.setStatus(SmsLogStatus.PENDING); + log.warn("SmsLog [{}] retry {}/{}: {}", smsLog.getId(), retries, MAX_RETRIES, e.getMessage()); + } + } + + logRepository.save(smsLog); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java new file mode 100644 index 0000000..57e731e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewRequest.java @@ -0,0 +1,6 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import java.util.Map; + +public record PreviewRequest(String template, Map variables) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java new file mode 100644 index 0000000..88d1640 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/PreviewResponse.java @@ -0,0 +1,4 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +public record PreviewResponse(String renderedContent, int segmentCount, String encoding, int charactersRemaining) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java new file mode 100644 index 0000000..1a25a3e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsCampaignController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.modules.sms.services.SmsCampaignService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + SmsCampaign.PLURAL) +public class SmsCampaignController extends BaseController { + + public SmsCampaignController(SmsCampaignService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java new file mode 100644 index 0000000..cbf93fb --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsLogController.java @@ -0,0 +1,24 @@ +package com.flexcodelabs.flextuma.modules.sms.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/" + SmsLog.PLURAL) +public class SmsLogController extends BaseController { + + public SmsLogController(SmsLogService service) { + super(service); + } + + @PostMapping("/{id}/retry") + public ResponseEntity retryFailedMessage(@PathVariable UUID id) { + SmsLog updatedLog = service.retryFailedMessage(id); + return ResponseEntity.ok(updatedLog); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java index 28b4243..6c9cec1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateController.java @@ -7,11 +7,31 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; +import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; + @RestController @RequestMapping("/api/" + SmsTemplate.PLURAL) public class SmsTemplateController extends BaseController { - public SmsTemplateController(SmsTemplateService service) { + private final SmsSegmentCalculator segmentCalculator; + + public SmsTemplateController(SmsTemplateService service, SmsSegmentCalculator segmentCalculator) { super(service); + this.segmentCalculator = segmentCalculator; + } + + @PostMapping("/preview") + public ResponseEntity preview(@RequestBody PreviewRequest request) { + String rendered = TemplateUtils.fillTemplate(request.template(), request.variables()); + SmsSegmentResult segments = segmentCalculator.calculate(rendered); + String encoding = segments.isGsm7() ? "GSM-7" : "UCS-2"; + return ResponseEntity + .ok(new PreviewResponse(rendered, segments.segments(), encoding, segments.charactersRemaining())); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java new file mode 100644 index 0000000..6c20b05 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java @@ -0,0 +1,78 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class SmsCampaignService extends BaseService { + + private final SmsCampaignRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return SmsCampaign.READ; + } + + @Override + protected String getAddPermission() { + return SmsCampaign.ADD; + } + + @Override + protected String getUpdatePermission() { + return SmsCampaign.UPDATE; + } + + @Override + protected String getDeletePermission() { + return SmsCampaign.DELETE; + } + + @Override + public String getEntityPlural() { + return SmsCampaign.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return SmsCampaign.PLURAL; + } + + @Override + protected String getEntitySingular() { + return SmsCampaign.NAME_SINGULAR; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected void onPreSave(SmsCampaign entity) { + if (entity.getStatus() == null) { + entity.setStatus(SmsCampaignStatus.SCHEDULED); + } + } + + @Override + protected void validateDelete(SmsCampaign entity) { + if (entity.getStatus() == SmsCampaignStatus.PROCESSING) { + throw new IllegalStateException("Cannot delete a campaign that is currently processing"); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java index cb12ad7..9e721ce 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java @@ -63,6 +63,36 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } + @Override + protected void onPreSave(SmsConnector entity) { + validateProviderConfig(entity); + } + + @Override + protected SmsConnector onPreUpdate(SmsConnector newEntity, SmsConnector oldEntity) { + SmsConnector merged = super.onPreUpdate(newEntity, oldEntity); + validateProviderConfig(merged); + return merged; + } + + private void validateProviderConfig(SmsConnector entity) { + String provider = entity.getProvider(); + if ("BEEM".equalsIgnoreCase(provider) || "NEXT".equalsIgnoreCase(provider)) { + if (entity.getUrl() == null || entity.getUrl().isBlank()) { + throw new IllegalArgumentException("URL is required for " + provider); + } + if (entity.getKey() == null || entity.getKey().isBlank()) { + throw new IllegalArgumentException("API Key is required for " + provider); + } + if (entity.getSecret() == null || entity.getSecret().isBlank()) { + throw new IllegalArgumentException("Secret Key is required for " + provider); + } + if (entity.getSenderId() == null || entity.getSenderId().isBlank()) { + throw new IllegalArgumentException("Sender ID is required for " + provider); + } + } + } + @Override protected void validateDelete(SmsConnector entity) { if (Boolean.TRUE.equals(entity.getActive())) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java new file mode 100644 index 0000000..f852db7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java @@ -0,0 +1,101 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Service +public class SmsLogService extends BaseService { + + private final SmsLogRepository smsLogRepository; + + public SmsLogService(SmsLogRepository repository) { + this.smsLogRepository = repository; + } + + @Override + protected org.springframework.data.jpa.repository.JpaRepository getRepository() { + return smsLogRepository; + } + + @Override + protected String getReadPermission() { + return SmsLog.READ; + } + + @Override + protected String getAddPermission() { + return SmsLog.ADD; + } + + @Override + protected String getUpdatePermission() { + return SmsLog.UPDATE; + } + + @Override + protected String getDeletePermission() { + return SmsLog.DELETE; + } + + @Override + public String getEntityPlural() { + return SmsLog.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return SmsLog.PLURAL; + } + + @Override + protected String getEntitySingular() { + return SmsLog.NAME_SINGULAR; + } + + @Override + protected org.springframework.data.jpa.repository.JpaSpecificationExecutor getRepositoryAsExecutor() { + return smsLogRepository; + } + + @Override + protected void onPreSave(SmsLog entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be created manually"); + } + + @Override + protected SmsLog onPreUpdate(SmsLog newEntity, SmsLog oldEntity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be updated manually"); + } + + @Override + protected void validateDelete(SmsLog entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, "SMS logs cannot be deleted"); + } + + @Transactional + public SmsLog retryFailedMessage(UUID id) { + checkPermission(SmsLog.UPDATE); + + SmsLog log = smsLogRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "SMS log not found")); + + if (log.getStatus() != SmsLogStatus.FAILED) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only failed messages can be retried"); + } + + log.setStatus(SmsLogStatus.PENDING); + log.setRetries(log.getRetries() + 1); + log.setError(null); + log.setProviderResponse(null); + + return smsLogRepository.save(log); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java new file mode 100644 index 0000000..0a4fe35 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/DispatchRequest.java @@ -0,0 +1,12 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import lombok.Data; +import java.util.Map; + +@Data +public class DispatchRequest { + private String templateCode; + private String content; + private String provider; + private Map filterQuery; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java new file mode 100644 index 0000000..9e4cffe --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookController.java @@ -0,0 +1,150 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import com.flexcodelabs.flextuma.core.entities.connector.ConnectorConfig; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.modules.connector.services.ConnectorConfigService; +import com.flexcodelabs.flextuma.modules.connector.services.DataHydratorService; +import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.webhooks.DlrParser; +import com.flexcodelabs.flextuma.core.webhooks.DlrResult; + +import lombok.extern.slf4j.Slf4j; + +/** + * Receives Delivery Report (DLR) callbacks from SMS providers. + * + *

+ * Endpoint: {@code POST /api/webhooks/sms/{provider}/dlr} + * + *

+ * The {@code provider} path variable must match the + * {@code SmsConnector.provider} + * value (e.g. {@code beem}, {@code next}). Matching is case-insensitive. + * + *

+ * The endpoint is unauthenticated β€” providers POST here without a session. + * CSRF is disabled globally so no token is required. + */ +@Slf4j +@RestController +@RequestMapping("/api/webhooks") +public class SmsWebhookController { + + private final SmsLogRepository logRepository; + private final List dlrParsers; + private final ConnectorConfigService configService; + private final DataHydratorService hydratorService; + private final NotificationService notificationService; + + public SmsWebhookController(SmsLogRepository logRepository, List dlrParsers, + ConnectorConfigService configService, DataHydratorService hydratorService, + NotificationService notificationService) { + this.logRepository = logRepository; + this.dlrParsers = dlrParsers; + this.configService = configService; + this.hydratorService = hydratorService; + this.notificationService = notificationService; + } + + @PostMapping("/{provider}") + public ResponseEntity deliveryReport( + @PathVariable String provider, + @RequestBody Map payload) { + + log.debug("DLR received from provider [{}]: {}", provider, payload); + + DlrParser parser = dlrParsers.stream() + .filter(p -> p.getProvider() != null && p.getProvider().equalsIgnoreCase(provider)) + .findFirst() + .orElse(null); + + if (parser == null) { + log.warn("No DLR parser registered for provider [{}] β€” ignoring (parsers={})", provider, dlrParsers.size()); + return ResponseEntity.ok().build(); + } + + DlrResult result = parser.parse(payload); + + if (result.messageId() == null || result.messageId().isBlank()) { + log.warn("DLR from [{}] missing message ID β€” payload: {}", provider, payload); + return ResponseEntity.ok().build(); + } + + if (result.status() == null) { + log.debug("DLR from [{}] has intermediate status [{}] β€” no update needed", provider, result.rawStatus()); + return ResponseEntity.ok().build(); + } + + Optional logOpt = logRepository.findByProviderResponse(result.messageId()); + + if (logOpt.isEmpty()) { + log.warn("DLR from [{}]: no SmsLog found for messageId [{}]", provider, result.messageId()); + return ResponseEntity.ok().build(); + } + + SmsLog smsLog = logOpt.get(); + + if (SmsLogStatus.SENT.equals(smsLog.getStatus()) && SmsLogStatus.FAILED.equals(result.status())) { + log.warn("DLR: ignoring FAILED update for already-SENT log [{}]", smsLog.getId()); + return ResponseEntity.ok().build(); + } + + smsLog.setStatus(result.status()); + logRepository.save(smsLog); + + log.info("DLR: SmsLog [{}] updated to [{}] via provider [{}]", smsLog.getId(), result.status(), provider); + + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/sms") + public ResponseEntity> triggerDispatch( + @PathVariable java.util.UUID id, + @RequestBody DispatchRequest request) { + + ConnectorConfig config = configService.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Connector config not found")); + + List> recipients = hydratorService.getRecipients(config.getTenantId(), + request.getFilterQuery()); + + String username = config.getCreatedBy() != null ? config.getCreatedBy().getUsername() : "system"; + + int queuedCount = 0; + for (Map recipient : recipients) { + recipient.put("provider", request.getProvider()); + try { + if (request.getContent() != null && !request.getContent().isBlank()) { + recipient.put("content", request.getContent()); + notificationService.queueRawSms(recipient, username); + } else { + recipient.put("templateCode", request.getTemplateCode()); + notificationService.queueTemplatedSms(recipient, username); + } + queuedCount++; + } catch (Exception e) { + log.error("Failed to queue SMS for recipient via webhook trigger", e); + } + } + + return ResponseEntity.ok(Map.of( + "message", "Successfully queued messages", + "queued", queuedCount, + "totalFetched", recipients.size())); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1c14310..af776f2 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,4 +23,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_L logging.level.org.springframework.orm.jpa=${JPA_LEVEL:DEBUG} logging.level.org.springframework.transaction=${TRANSACTION_LEVEL:DEBUG} logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper=${SQL_EXCEPTION_HELPER_LEVEL:DEBUG} -spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} \ No newline at end of file +spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} + +# SMS Pricing +flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} \ No newline at end of file diff --git a/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java b/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java new file mode 100644 index 0000000..bc8b2ca --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/aspects/FeatureGateAspectTest.java @@ -0,0 +1,104 @@ +package com.flexcodelabs.flextuma.core.aspects; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.annotations.FeatureGate; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.TenantFeatureRepository; + +@ExtendWith(MockitoExtension.class) +class FeatureGateAspectTest { + + @Mock + private CurrentUserResolver currentUserResolver; + + @Mock + private TenantFeatureRepository featureRepository; + + @InjectMocks + private FeatureGateAspect aspect; + + @Mock + private FeatureGate gate; + + private Organisation organisation; + private User user; + + @BeforeEach + void setUp() { + organisation = new Organisation(); + user = new User(); + user.setOrganisation(organisation); + when(gate.value()).thenReturn("BULK_CAMPAIGN"); + } + + @Test + void checkFeature_shouldThrowForbidden_whenFeatureIsDisabled() { + TenantFeature feature = new TenantFeature(); + feature.setEnabled(false); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.of(feature)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> aspect.checkFeature(gate)); + + assertEquals(403, ex.getStatusCode().value()); + assertTrue(ex.getReason().contains("BULK_CAMPAIGN")); + } + + @Test + void checkFeature_shouldAllow_whenFeatureIsEnabled() { + TenantFeature feature = new TenantFeature(); + feature.setEnabled(true); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.of(feature)); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + } + + @Test + void checkFeature_shouldAllow_whenNoRecordExists() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(featureRepository.findByOrganisationAndFeatureKey(organisation, "BULK_CAMPAIGN")) + .thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + } + + @Test + void checkFeature_shouldBypass_whenUserHasNoOrganisation() { + User systemUser = new User(); + systemUser.setOrganisation(null); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(systemUser)); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + verifyNoInteractions(featureRepository); + } + + @Test + void checkFeature_shouldBypass_whenNoAuthenticatedUser() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + + assertDoesNotThrow(() -> aspect.checkFeature(gate)); + verifyNoInteractions(featureRepository); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java new file mode 100644 index 0000000..27ad235 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTest.java @@ -0,0 +1,36 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import static org.junit.jupiter.api.Assertions.*; + +class WalletTest { + + @Test + void testDefaultConstructorAndInitialValues() { + Wallet wallet = new Wallet(); + assertEquals(BigDecimal.ZERO, wallet.getBalance()); + assertEquals("TZS", wallet.getCurrency()); + assertNull(wallet.getVersion()); + } + + @Test + void testGettersAndSetters() { + Wallet wallet = new Wallet(); + wallet.setBalance(new BigDecimal("150.75")); + wallet.setCurrency("USD"); + wallet.setVersion(2L); + + assertEquals(new BigDecimal("150.75"), wallet.getBalance()); + assertEquals("USD", wallet.getCurrency()); + assertEquals(2L, wallet.getVersion()); + } + + @Test + void testAllArgsConstructor() { + Wallet wallet = new Wallet(new BigDecimal("200.00"), "KES", 1L); + assertEquals(new BigDecimal("200.00"), wallet.getBalance()); + assertEquals("KES", wallet.getCurrency()); + assertEquals(1L, wallet.getVersion()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java new file mode 100644 index 0000000..f379b7a --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/entities/finance/WalletTransactionTest.java @@ -0,0 +1,48 @@ +package com.flexcodelabs.flextuma.core.entities.finance; + +import com.flexcodelabs.flextuma.core.enums.TransactionType; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import static org.junit.jupiter.api.Assertions.*; + +class WalletTransactionTest { + + @Test + void testGettersAndSetters() { + WalletTransaction transaction = new WalletTransaction(); + Wallet wallet = new Wallet(); + + transaction.setWallet(wallet); + transaction.setType(TransactionType.CREDIT); + transaction.setAmount(new BigDecimal("50.00")); + transaction.setDescription("Test Credit"); + transaction.setReference("REF-123"); + transaction.setBalanceAfter(new BigDecimal("150.00")); + + assertEquals(wallet, transaction.getWallet()); + assertEquals(TransactionType.CREDIT, transaction.getType()); + assertEquals(new BigDecimal("50.00"), transaction.getAmount()); + assertEquals("Test Credit", transaction.getDescription()); + assertEquals("REF-123", transaction.getReference()); + assertEquals(new BigDecimal("150.00"), transaction.getBalanceAfter()); + } + + @Test + void testAllArgsConstructor() { + Wallet wallet = new Wallet(); + WalletTransaction transaction = new WalletTransaction( + wallet, + TransactionType.DEBIT, + new BigDecimal("25.00"), + "Test Debit", + "REF-456", + new BigDecimal("75.00")); + + assertEquals(wallet, transaction.getWallet()); + assertEquals(TransactionType.DEBIT, transaction.getType()); + assertEquals(new BigDecimal("25.00"), transaction.getAmount()); + assertEquals("Test Debit", transaction.getDescription()); + assertEquals("REF-456", transaction.getReference()); + assertEquals(new BigDecimal("75.00"), transaction.getBalanceAfter()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java b/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java new file mode 100644 index 0000000..dbb7f0f --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/enums/TransactionTypeTest.java @@ -0,0 +1,22 @@ +package com.flexcodelabs.flextuma.core.enums; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TransactionTypeTest { + + @Test + void testEnumValuesAndValueOf() { + // Test values structure + TransactionType[] types = TransactionType.values(); + assertEquals(2, types.length); + + // Test valueOf + assertEquals(TransactionType.CREDIT, TransactionType.valueOf("CREDIT")); + assertEquals(TransactionType.DEBIT, TransactionType.valueOf("DEBIT")); + + // Assert specific enum ordinals/names just to guarantee they exist safely + assertEquals("CREDIT", TransactionType.CREDIT.name()); + assertEquals("DEBIT", TransactionType.DEBIT.name()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java new file mode 100644 index 0000000..6fe2181 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/CustomPropertyFilterTest.java @@ -0,0 +1,132 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.ser.FilterProvider; +import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class CustomPropertyFilterTest { + + private ObjectMapper objectMapper; + private MockHttpServletRequest request; + + @JsonFilter("customFilter") + static class TestEntity { + public UUID id = UUID.randomUUID(); + public String name = "Test Name"; + public String description = "Test Description"; + public int page = 1; // technical field + public NestedEntity nested = new NestedEntity(); + public TestEntity circular; // for circular reference test + } + + @JsonFilter("customFilter") + static class NestedEntity { + public String details = "Nested Details"; + public String hidden = "Should Be Hidden"; + } + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.disable(SerializationFeature.FAIL_ON_SELF_REFERENCES); + FilterProvider filters = new SimpleFilterProvider() + .addFilter("customFilter", new CustomPropertyFilter()); + objectMapper.setFilterProvider(filters); + + request = new MockHttpServletRequest(); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @AfterEach + void tearDown() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + void shouldSerializeAllFieldsWhenNoFieldsParam() throws JsonProcessingException { + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + assertTrue(json.contains("id")); + assertTrue(json.contains("name")); + assertTrue(json.contains("description")); + assertTrue(json.contains("page")); + assertTrue(json.contains("nested")); + assertTrue(json.contains("details")); + assertTrue(json.contains("hidden")); + } + + @Test + void shouldSerializeAllFieldsWhenFieldsParamIsAsterisk() throws JsonProcessingException { + request.setParameter("fields", "*"); + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + assertTrue(json.contains("name")); + assertTrue(json.contains("description")); + assertTrue(json.contains("nested")); + } + + @Test + void shouldFilterFieldsBasedOnParam() throws JsonProcessingException { + request.setParameter("fields", "name,nested[details]"); + TestEntity entity = new TestEntity(); + String json = objectMapper.writeValueAsString(entity); + + // Technical fields are always included + assertTrue(json.contains("id")); + assertTrue(json.contains("page")); + + // Requested fields are included + assertTrue(json.contains("name")); + assertTrue(json.contains("nested")); + assertTrue(json.contains("details")); + + // Non-requested fields are excluded + assertFalse(json.contains("description")); + assertFalse(json.contains("hidden")); + } + + @Test + void shouldHandleCircularReferencesGracefully() throws JsonProcessingException { + request.setParameter("fields", "*"); + TestEntity entity = new TestEntity(); + entity.circular = entity; // Create circular reference + + String json = objectMapper.writeValueAsString(entity); + + // Should not throw StackOverflowError and should serialize successfully + assertTrue(json.contains("name")); + } + + @Test + void shouldHandleDeepNestingGracefully() throws JsonProcessingException { + // Build a deeply nested structure (depth > 10) + TestEntity root = new TestEntity(); + TestEntity current = root; + for (int i = 0; i < 15; i++) { + current.circular = new TestEntity(); + current = current.circular; + } + + request.setParameter("fields", "*"); + String json = objectMapper.writeValueAsString(root); + + // Should not throw StackOverflowError and should only serialize up to depth + // limit + assertNotNull(json); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java new file mode 100644 index 0000000..4203cf2 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/GenericSpecificationTest.java @@ -0,0 +1,226 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.criteria.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class GenericSpecificationTest { + + @Mock + private Root root; + + @Mock + private CriteriaQuery query; + + @Mock + private CriteriaBuilder cb; + + @Mock + private Path path; + + @Mock + private Path stringPath; + + @Mock + private Predicate predicate; + + @Mock + private CriteriaBuilder.In inClause; + + static class TestEntity extends BaseEntity { + // dummy entity + } + + enum TestEnum { + ONE, TWO + } + + @BeforeEach + void setUp() { + // Set up the path mock for standard operations. Note that "javaType" mocking is + // important! + } + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private void mockPathForType(Class type) { + when(root.get(anyString())).thenReturn((Path) path); + lenient().when(path.getJavaType()).thenReturn((Class) type); + } + + @Test + void testEqOperatorString() { + mockPathForType(String.class); + when(cb.equal(path, "value")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:EQ:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, "value"); + } + + @Test + void testEqOperatorUUID() { + mockPathForType(UUID.class); + UUID id = UUID.randomUUID(); + when(cb.equal(path, id)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("id:EQ:" + id); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, id); + } + + @Test + void testEqOperatorBoolean() { + mockPathForType(Boolean.class); + when(cb.equal(path, true)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("active:EQ:true"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, true); + } + + @Test + void testEqOperatorEnum() { + mockPathForType(TestEnum.class); + when(cb.equal(path, TestEnum.ONE)).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:EQ:ONE"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, TestEnum.ONE); + } + + @Test + void testNeOperator() { + mockPathForType(String.class); + when(cb.notEqual(path, "value")).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:NE:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).notEqual(path, "value"); + } + + @Test + void testLikeOperator() { + mockPathForType(String.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lower(any())).thenReturn(stringPath); + when(cb.like(any(), anyString())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:LIKE:value"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).like(any(), eq("%value%")); + } + + @Test + void testILikeOperator() { + mockPathForType(String.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lower(any())).thenReturn(stringPath); + when(cb.like(any(), anyString())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:ILIKE:VaLuE"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).like(any(), eq("%value%")); + } + + @Test + void testInOperator() { + mockPathForType(String.class); + when(path.in(anyList())).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:IN:A,B,C"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(path).in(anyList()); + } + + @Test + void testGtOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.greaterThan(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:GT:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).greaterThan(any(), eq("10")); + } + + @Test + void testLtOperator() { + mockPathForType(Integer.class); + when(path.as(String.class)).thenReturn(stringPath); + when(cb.lessThan(any(), eq("10"))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("age:LT:10"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).lessThan(any(), eq("10")); + } + + @Test + void testNullOperator() { + mockPathForType(String.class); + lenient().when(cb.equal(any(Expression.class), eq((Object) null))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("name:EQ:null"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, (Object) null); + } + + @Test + void testInvalidEnumShouldReturnNullForValue() { + mockPathForType(TestEnum.class); + lenient().when(cb.equal(any(Expression.class), eq((Object) null))).thenReturn(predicate); + + GenericSpecification spec = new GenericSpecification<>("status:EQ:INVALID_ENUM"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + // an invalid enum returns null so it calls cb.equal(path, null) + verify(cb).equal(path, (Object) null); + } + + @Test + void testMissingValue() { + mockPathForType(String.class); + when(cb.equal(path, "")).thenReturn(predicate); + + // String splitting "field:EQ" shouldn't throw but defaults value to "" + GenericSpecification spec = new GenericSpecification<>("name:EQ"); + Predicate p = spec.toPredicate(root, query, cb); + + assertNotNull(p); + verify(cb).equal(path, ""); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java new file mode 100644 index 0000000..030893d --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/helpers/SmsSegmentCalculatorTest.java @@ -0,0 +1,56 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SmsSegmentCalculatorTest { + + @Test + void testEmptyMessage() { + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(""); + assertEquals(0, result.segments()); + assertEquals(0, result.length()); + assertTrue(result.isGsm7()); + assertEquals(160, result.charactersRemaining()); + } + + @Test + void testNullMessage() { + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(null); + assertEquals(0, result.segments()); + assertEquals(0, result.length()); + assertTrue(result.isGsm7()); + assertEquals(160, result.charactersRemaining()); + } + + @ParameterizedTest + @CsvSource({ + // GSM-7 Test Cases + "Hello World, 1, true, 11, 149", + "This is a standard message that fits exactly in one single segment without any special formatting thus taking one segment 0123456789 0123456789 012345678, 1, true, 153, 7", + "This is a standard message that exceeds one single segment so it will be split into two segments as it has more than strictly one hundred and sixty character length in gsm7., 2, true, 173, 133", + "Hello {}, 1, true, 10, 150", + "Habari ya asubuhi, 1, true, 17, 143", + "Swahili text without weird chars, 1, true, 32, 128", + + // Unicode Test Cases + "Hello 🌍, 1, false, 8, 62", + "This message contains special characters \u00E1 which makes it non gsm7 actually Γ‘ is extended in gsm wait no Γ‘ is not in default gsm7 alphabet, 3, false, 138, 63", + "Mambo vipi πŸŽ‰ Tuna ofa mpya kwako! Njoo ujipatie punguzo la asilimia kumi kwa kila bidhaa utakayonunua., 2, false, 103, 31" + }) + void testSegmentCalculations(String message, int expectedSegments, boolean expectedGsm7, int expectedLength, + int expectedRemaining) { + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + SmsSegmentResult result = calculator.calculate(message); + assertEquals(expectedSegments, result.segments()); + assertEquals(expectedGsm7, result.isGsm7()); + assertEquals(expectedLength, result.length()); + assertEquals(expectedRemaining, result.charactersRemaining()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java new file mode 100644 index 0000000..0ec3311 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/security/PatAuthenticationFilterTest.java @@ -0,0 +1,102 @@ +package com.flexcodelabs.flextuma.core.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.flexcodelabs.flextuma.core.entities.auth.PersonalAccessToken; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.repositories.PersonalAccessTokenRepository; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class PatAuthenticationFilterTest { + + @Mock + private PersonalAccessTokenRepository patRepository; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private PatAuthenticationFilter filter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @Test + void doFilterInternal_WithValidToken_AuthenticatesUser() throws ServletException, IOException { + String rawToken = "test-token"; + String hashedToken = hashToken(rawToken); + + User user = new User(); + user.setUsername("testuser"); + user.setRoles(Collections.emptySet()); + + PersonalAccessToken pat = new PersonalAccessToken(); + pat.setToken(hashedToken); + pat.setUser(user); + pat.setExpiresAt(LocalDateTime.now().plusDays(1)); + + when(request.getHeader("X-API-KEY")).thenReturn(rawToken); + when(patRepository.findByToken(hashedToken)).thenReturn(Optional.of(pat)); + + filter.doFilterInternal(request, response, filterChain); + + assertNotNull(SecurityContextHolder.getContext().getAuthentication()); + assertEquals("testuser", SecurityContextHolder.getContext().getAuthentication().getPrincipal()); + verify(patRepository).save(pat); + verify(filterChain).doFilter(request, response); + } + + @Test + void doFilterInternal_WithInvalidToken_DoesNotAuthenticate() throws ServletException, IOException { + String rawToken = "invalid-token"; + String hashedToken = hashToken(rawToken); + + when(request.getHeader("X-API-KEY")).thenReturn(rawToken); + when(patRepository.findByToken(hashedToken)).thenReturn(Optional.empty()); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } + + private String hashToken(String token) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return java.util.HexFormat.of().formatHex(hash); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java b/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java index 2b9daa9..2fbd083 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/senders/BeamSenderTest.java @@ -18,13 +18,13 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class BeamSenderTest { +class BeemSenderTest { @Mock private RestTemplate restTemplate; @InjectMocks - private BeamSender beamSender; + private BeemSender beemSender; private SmsConnector config; @@ -38,20 +38,20 @@ void setUp() { } @Test - void getProvider_shouldReturnBeam() { - assertEquals("BEAM", beamSender.getProvider()); + void getProvider_shouldReturnBeem() { + assertEquals("BEEM", beemSender.getProvider()); } @Test void sendSms_shouldReturnSuccess_whenApiCallIsSuccessful() { - BeamSender.BeamSmsResponse responseBody = new BeamSender.BeamSmsResponse(true, "SMS sent successfully", 100); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); + BeemSender.BeemSmsResponse responseBody = new BeemSender.BeemSmsResponse(true, "SMS sent successfully", 100); + ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenReturn(responseEntity); - String result = beamSender.sendSms(config, "255712345678", "Hello World"); + String result = beemSender.sendSms(config, "255712345678", "Hello World"); assertEquals("SMS sent successfully", result); } @@ -59,14 +59,14 @@ void sendSms_shouldReturnSuccess_whenApiCallIsSuccessful() { @Test void sendSms_shouldReturnSuccess_whenResponseIsNull() { // If body is null, it defaults to "SUCCESS" - ResponseEntity responseEntity = new ResponseEntity<>( - (BeamSender.BeamSmsResponse) null, HttpStatus.OK); + ResponseEntity responseEntity = new ResponseEntity<>( + (BeemSender.BeemSmsResponse) null, HttpStatus.OK); when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenReturn(responseEntity); - String result = beamSender.sendSms(config, "255712345678", "Hello World"); + String result = beemSender.sendSms(config, "255712345678", "Hello World"); assertEquals("SUCCESS", result); } @@ -74,9 +74,9 @@ void sendSms_shouldReturnSuccess_whenResponseIsNull() { @Test void sendSms_shouldThrowException_whenConnectionFails() { when(restTemplate.postForEntity(eq(config.getUrl()), any(HttpEntity.class), - eq(BeamSender.BeamSmsResponse.class))) + eq(BeemSender.BeemSmsResponse.class))) .thenThrow(new org.springframework.web.client.ResourceAccessException("Connection failed")); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> beamSender.sendSms(config, "255712345678", "Hello World")); + () -> beemSender.sendSms(config, "255712345678", "Hello World")); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java index f0aa7e5..b83017c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/BaseServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import java.util.List; import java.util.Optional; import java.util.Set; @@ -47,6 +48,9 @@ class BaseServiceTest { @Mock private EntityManager entityManager; + @Mock + private CurrentUserResolver currentUserResolver; + @Mock private SecurityContext securityContext; @@ -59,6 +63,9 @@ class BaseServiceTest { void setUp() { service = new TestService(repository, specificationExecutor); service.entityManager = entityManager; + service.setCurrentUserResolver(currentUserResolver); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); securityContextHolderMock = Mockito.mockStatic(SecurityContextHolder.class); securityContextHolderMock.when(SecurityContextHolder::getContext).thenReturn(securityContext); @@ -218,7 +225,6 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { @Test void onPreUpdate_shouldReturnNewEntity_whenExceptionOccurs() { - // We need to inject a mock ObjectMapper to force an exception com.fasterxml.jackson.databind.ObjectMapper mockMapper = mock( com.fasterxml.jackson.databind.ObjectMapper.class); org.springframework.test.util.ReflectionTestUtils.setField(service, "objectMapper", mockMapper); diff --git a/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java new file mode 100644 index 0000000..cd9ef2b --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/services/RateLimiterServiceTest.java @@ -0,0 +1,47 @@ +package com.flexcodelabs.flextuma.core.services; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RateLimiterServiceTest { + + @Test + void testCheckRateLimitAllowsRequestsWithinLimit() { + RateLimiterService rateLimiterService = new RateLimiterService(); + UUID tenantId = UUID.randomUUID(); + + // 10 requests should be allowed + for (int i = 0; i < 10; i++) { + assertDoesNotThrow(() -> rateLimiterService.checkRateLimit(tenantId)); + } + } + + @Test + void testCheckRateLimitThrowsWhenExceeded() { + RateLimiterService rateLimiterService = new RateLimiterService(); + UUID tenantId = UUID.randomUUID(); + + // Consume all 10 tokens + for (int i = 0; i < 10; i++) { + rateLimiterService.checkRateLimit(tenantId); + } + + // 11th request should throw Exception + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> rateLimiterService.checkRateLimit(tenantId)); + + assertEquals(HttpStatus.TOO_MANY_REQUESTS, exception.getStatusCode()); + assertTrue(exception.getReason().contains("Rate limit exceeded")); + } + + @Test + void testCheckRateLimitIgnoresNullTenantId() { + RateLimiterService rateLimiterService = new RateLimiterService(); + assertDoesNotThrow(() -> rateLimiterService.checkRateLimit(null)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java index dec81a6..2abad48 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/connector/services/DataHydratorServiceTest.java @@ -10,6 +10,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; import java.util.List; @@ -42,13 +43,16 @@ class DataHydratorServiceTest { @Mock private RestClient.ResponseSpec responseSpec; + @Mock + private ObjectMapper objectMapper; + private DataHydratorService service; @BeforeEach void setUp() { // Mock the builder chain when(restClientBuilder.build()).thenReturn(restClient); - service = new DataHydratorService(repository, restClientBuilder); + service = new DataHydratorService(repository, restClientBuilder, objectMapper); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java new file mode 100644 index 0000000..1a273e5 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/feature/controllers/TenantFeatureControllerTest.java @@ -0,0 +1,39 @@ +package com.flexcodelabs.flextuma.modules.feature.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseControllerTest; +import com.flexcodelabs.flextuma.core.entities.feature.TenantFeature; +import com.flexcodelabs.flextuma.modules.feature.services.TenantFeatureService; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TenantFeatureControllerTest extends BaseControllerTest { + + @Mock + private TenantFeatureService service; + + @Override + protected TenantFeatureController getController() { + return new TenantFeatureController(service); + } + + @Override + protected TenantFeatureService getService() { + return service; + } + + @Override + protected TenantFeature createEntity() { + TenantFeature feature = new TenantFeature(); + feature.setFeatureKey("BULK_CAMPAIGN"); + feature.setEnabled(true); + return feature; + } + + @Override + protected String getBaseUrl() { + return "/api/" + TenantFeature.PLURAL; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java index 45da594..461e45a 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java @@ -2,12 +2,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import java.security.Principal; import java.util.HashMap; import java.util.Map; @@ -21,6 +20,8 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; @ExtendWith(MockitoExtension.class) @@ -31,10 +32,7 @@ class NotificationControllerTest { @Mock private NotificationService notificationService; - @Mock - private Principal principal; - - private ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { @@ -43,19 +41,24 @@ void setUp() { } @Test - void sendSms_shouldQueueSms_whenParametersValid() throws Exception { + void send_shouldReturnSmsLog_whenParametersValid() throws Exception { Map variables = new HashMap<>(); - variables.put("phone", "1234567890"); - variables.put("code", "1234"); - // Principal mock setup if needed, but we pass it directly + variables.put("phoneNumber", "255700000000"); + variables.put("templateCode", "OTP"); + variables.put("provider", "beem"); + + SmsLog queued = new SmsLog(); + queued.setStatus(SmsLogStatus.PENDING); + queued.setRecipient("255700000000"); + + when(notificationService.queueTemplatedSms(any(), eq("testuser"))).thenReturn(queued); mockMvc.perform(post("/api/notifications") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(variables)) .principal(() -> "testuser")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("SMS request queued successfully")); - - verify(notificationService).sendTemplatedSms(any(), eq("testuser")); + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.recipient").value("255700000000")); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java new file mode 100644 index 0000000..76b1b36 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -0,0 +1,186 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; +import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; + +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.test.util.ReflectionTestUtils; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import com.flexcodelabs.flextuma.core.services.RateLimiterService; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock + private SmsTemplateRepository templateRepository; + + @Mock + private SmsLogRepository logRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private SmsConnectorRepository connectorRepository; + + @Mock + private WalletService walletService; + + @Mock + private RateLimiterService rateLimiterService; + + @Mock + private SmsSegmentCalculator segmentCalculator; + + @InjectMocks + private NotificationService notificationService; + + @Captor + private ArgumentCaptor smsLogCaptor; + + private User testUser; + private Map validPlaceholders; + + @BeforeEach + void setUp() { + testUser = new User(); + testUser.setUsername("testuser"); + + validPlaceholders = new HashMap<>(); + validPlaceholders.put("provider", "Twilio"); + validPlaceholders.put("templateCode", "WELCOME"); + validPlaceholders.put("phoneNumber", "+1234567890"); + validPlaceholders.put("name", "John Doe"); // Custom placeholder + + ReflectionTestUtils.setField(notificationService, "pricePerSegment", BigDecimal.valueOf(20.0)); + } + + @Test + void queueTemplatedSms_shouldThrowWhenUsernameIsNull() { + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, null)); + + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not authenticated")); + } + + @Test + void queueTemplatedSms_shouldThrowWhenUserNotFound() { + when(userRepository.findByUsername("unknown")).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "unknown")); + + assertEquals(HttpStatus.UNAUTHORIZED, ex.getStatusCode()); + assertTrue(ex.getReason().contains("User not found")); + } + + @ParameterizedTest + @CsvSource({ + "provider, SMS provider is missing", + "templateCode, Template is missing", + "phoneNumber, Phone number is missing" + }) + void queueTemplatedSms_shouldThrowWhenRequiredPlaceholderMissing(String missingKey, String expectedMessage) { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + validPlaceholders.remove(missingKey); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains(expectedMessage)); + } + + @Test + void queueTemplatedSms_shouldThrowWhenTemplateNotFound() { + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Template not found or you don't have access to it")); + } + + @Test + void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("No active SMS connector found")); + } + + @Test + void queueTemplatedSms_shouldQueueSmsSuccessfully() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); + + SmsConnector connector = new SmsConnector(); + connector.setProvider("Twilio"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + .thenReturn(Optional.of(connector)); + + SmsSegmentResult mockSegmentResult = new SmsSegmentResult(1, true, 14, 146); + when(segmentCalculator.calculate(anyString())).thenReturn(mockSegmentResult); + + SmsLog expectedSavedLog = new SmsLog(); + expectedSavedLog.setStatus(SmsLogStatus.PENDING); + when(logRepository.save(any(SmsLog.class))).thenReturn(expectedSavedLog); + + SmsLog result = notificationService.queueTemplatedSms(validPlaceholders, "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + SmsLog capturedLog = smsLogCaptor.getValue(); + + assertEquals("+1234567890", capturedLog.getRecipient()); + assertEquals("Hello John Doe", capturedLog.getContent()); + assertEquals(template, capturedLog.getTemplate()); + assertEquals(connector, capturedLog.getConnector()); + assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); + + assertNotNull(result); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java new file mode 100644 index 0000000..fc2b9ea --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -0,0 +1,127 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.services.SmsSender; + +@ExtendWith(MockitoExtension.class) +class SmsDispatchWorkerTest { + + @Mock + private SmsLogRepository logRepository; + + @Mock + private SmsSender smsSender; + + private SmsDispatchWorker worker; + + @BeforeEach + void setUp() { + worker = new SmsDispatchWorker(logRepository, List.of(smsSender)); + } + + private SmsLog pendingLog(String provider) { + SmsConnector connector = new SmsConnector(); + connector.setProvider(provider); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PENDING); + log.setRecipient("255700000000"); + log.setContent("Test message"); + log.setConnector(connector); + log.setRetries(0); + return log; + } + + @Test + void dispatch_shouldMarkSent_whenSendSucceeds() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenReturn("provider-msg-id-123"); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.SENT, log.getStatus()); + assertEquals("provider-msg-id-123", log.getProviderResponse()); + } + + @Test + void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.PENDING, log.getStatus()); + assertEquals(1, log.getRetries()); + } + + @Test + void dispatch_shouldMarkFailed_whenMaxRetriesReached() { + SmsLog log = pendingLog("beem"); + log.setRetries(2); + + when(smsSender.getProvider()).thenReturn("beem"); + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(List.of(log)); + when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + assertEquals(3, log.getRetries()); + } + + @Test + void dispatch_shouldMarkFailed_whenNoConnector() { + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PENDING); + log.setConnector(null); + + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(List.of(log)); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + worker.dispatch(); + + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + } + + @Test + void dispatch_shouldDoNothing_whenNoPendingLogs() { + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(Collections.emptyList()); + + worker.dispatch(); + + verify(logRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java index 3ce55e3..9b2832c 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/controllers/SmsTemplateControllerTest.java @@ -1,13 +1,22 @@ package com.flexcodelabs.flextuma.modules.sms.controllers; +import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.springframework.http.MediaType; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.flexcodelabs.flextuma.core.controllers.BaseController; import com.flexcodelabs.flextuma.core.controllers.BaseControllerTest; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.modules.sms.services.SmsTemplateService; +import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; + +import java.util.Map; -public class SmsTemplateControllerTest extends BaseControllerTest { +class SmsTemplateControllerTest extends BaseControllerTest { @Mock private SmsTemplateService service; @@ -17,7 +26,8 @@ public class SmsTemplateControllerTest extends BaseControllerTest getController() { if (controller == null) { - controller = new SmsTemplateController(service); + SmsSegmentCalculator calculator = new SmsSegmentCalculator(); + controller = new SmsTemplateController(service, calculator); } return controller; } @@ -38,4 +48,19 @@ protected SmsTemplate createEntity() { protected String getBaseUrl() { return "/api/templates"; } + + @Test + void preview_shouldRenderContentAndCalculateSegments() throws Exception { + PreviewRequest req = new PreviewRequest("Hello {{name}}, your code is {{code}}", + Map.of("name", "Alice", "code", "1234")); + + mockMvc.perform(post(getBaseUrl() + "/preview") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.renderedContent").value("Hello Alice, your code is 1234")) + .andExpect(jsonPath("$.segmentCount").value(1)) + .andExpect(jsonPath("$.encoding").value("GSM-7")) + .andExpect(jsonPath("$.charactersRemaining").value(130)); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java new file mode 100644 index 0000000..5816026 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -0,0 +1,114 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SmsLogServiceTest { + + @Mock + private SmsLogRepository smsLogRepository; + + @InjectMocks + private SmsLogService smsLogService; + + private UUID logId; + private SmsLog smsLog; + + @BeforeEach + void setUp() { + logId = UUID.randomUUID(); + smsLog = new SmsLog(); + smsLog.setId(logId); + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setRetries(1); + smsLog.setError("Network timeout"); + smsLog.setProviderResponse("HTTP 504"); + } + + @Test + void retryFailedMessage_Success() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("SUPER_ADMIN")); + + when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); + when(smsLogRepository.save(any(SmsLog.class))).thenAnswer(i -> i.getArgument(0)); + + SmsLog retriedLog = smsLogService.retryFailedMessage(logId); + + assertNotNull(retriedLog); + assertEquals(SmsLogStatus.PENDING, retriedLog.getStatus()); + assertEquals(2, retriedLog.getRetries()); + assertNull(retriedLog.getError()); + assertNull(retriedLog.getProviderResponse()); + + verify(smsLogRepository).save(smsLog); + } + } + + @Test + void retryFailedMessage_LogNotFound() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("UPDATE_SMS_LOGS")); + + when(smsLogRepository.findById(logId)).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> smsLogService.retryFailedMessage(logId)); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + } + } + + @Test + void retryFailedMessage_NotFailedStatus() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of(SmsLog.UPDATE)); + + smsLog.setStatus(SmsLogStatus.SENT); + when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> smsLogService.retryFailedMessage(logId)); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Only failed messages")); + } + } + + @Test + void retryFailedMessage_NoPermission() { + try (MockedStatic utils = mockStatic( + com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { + utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) + .thenReturn(Set.of("READ_SMS_LOGS")); + + assertThrows(AccessDeniedException.class, () -> smsLogService.retryFailedMessage(logId)); + } + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java new file mode 100644 index 0000000..68a55ba --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/webhook/controllers/SmsWebhookControllerTest.java @@ -0,0 +1,196 @@ +package com.flexcodelabs.flextuma.modules.webhook.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.entities.connector.ConnectorConfig; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.modules.connector.services.ConnectorConfigService; +import com.flexcodelabs.flextuma.modules.connector.services.DataHydratorService; +import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.webhooks.DlrParser; +import com.flexcodelabs.flextuma.core.webhooks.DlrResult; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class SmsWebhookControllerTest { + + @Mock + private SmsLogRepository logRepository; + + @Mock + private DlrParser dlrParser; + + @Mock + private ConnectorConfigService configService; + + @Mock + private DataHydratorService hydratorService; + + @Mock + private NotificationService notificationService; + + private SmsWebhookController buildController() { + return new SmsWebhookController(logRepository, List.of(dlrParser), configService, hydratorService, + notificationService); + } + + private Map payload(String msgId, String status) { + Map map = new HashMap<>(); + map.put("messageID", msgId); + map.put("status", status); + return map; + } + + @Test + void deliveryReport_shouldUpdateToSent_whenDelivered() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-123", SmsLogStatus.SENT, "delivered")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.SENT); + when(logRepository.findByProviderResponse("msg-123")).thenReturn(Optional.of(log)); + when(logRepository.save(any())).thenReturn(log); + + buildController().deliveryReport("beem", payload("msg-123", "delivered")); + + verify(logRepository).findByProviderResponse("msg-123"); + verify(logRepository).save(any()); + assertEquals(SmsLogStatus.SENT, log.getStatus()); + } + + @Test + void deliveryReport_shouldUpdateToFailed_whenFailed() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-789", SmsLogStatus.FAILED, "failed")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.PROCESSING); + when(logRepository.findByProviderResponse("msg-789")).thenReturn(Optional.of(log)); + when(logRepository.save(any())).thenReturn(log); + + buildController().deliveryReport("beem", payload("msg-789", "failed")); + + verify(logRepository).save(any()); + assertEquals(SmsLogStatus.FAILED, log.getStatus()); + } + + @Test + void deliveryReport_shouldNotSave_whenAlreadySentAndFailedDlrArrives() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-456", SmsLogStatus.FAILED, "failed")); + + SmsLog log = new SmsLog(); + log.setStatus(SmsLogStatus.SENT); + lenient().when(logRepository.findByProviderResponse("msg-456")).thenReturn(Optional.of(log)); + + buildController().deliveryReport("beem", payload("msg-456", "failed")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenUnknownProvider() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + + buildController().deliveryReport("unknown_provider", payload("msg-000", "delivered")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenNoLogFound() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-999", SmsLogStatus.SENT, "delivered")); + + buildController().deliveryReport("beem", payload("msg-999", "delivered")); + + verify(logRepository, never()).save(any()); + } + + @Test + void deliveryReport_shouldNotSave_whenIntermediateStatus() { + when(dlrParser.getProvider()).thenReturn("BEEM"); + when(dlrParser.parse(any())).thenReturn(new DlrResult("msg-001", null, "submitted")); + + buildController().deliveryReport("beem", payload("msg-001", "submitted")); + + verify(logRepository, never()).save(any()); + } + + @Test + void triggerDispatch_shouldCallHydratorAndQueueSms_systemUser() { + UUID id = UUID.randomUUID(); + ConnectorConfig config = new ConnectorConfig(); + config.setId(id); + config.setTenantId("tenant2"); + // No createdBy, so it should fallback to "system" + + when(configService.findById(id)).thenReturn(Optional.of(config)); + + List> mockRecipients = List.of( + new HashMap<>(Map.of("phoneNumber", "+254700000000")), + new HashMap<>(Map.of("phoneNumber", "+254711111111"))); + when(hydratorService.getRecipients(eq("tenant2"), any())).thenReturn(mockRecipients); + + DispatchRequest req = new DispatchRequest(); + req.setTemplateCode("ALERT"); + req.setProvider("beem"); + req.setFilterQuery(Map.of("group", "ALL")); + + ResponseEntity> res = buildController().triggerDispatch(id, req); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(2, res.getBody().get("queued")); + assertEquals(2, res.getBody().get("totalFetched")); + + verify(notificationService, times(2)).queueTemplatedSms(any(), eq("system")); + } + + @Test + void triggerDispatch_shouldCallHydratorAndQueueSms_withCreatedBy() { + UUID id = UUID.randomUUID(); + ConnectorConfig config = new ConnectorConfig(); + config.setId(id); + config.setTenantId("tenant3"); + User creator = new User(); + creator.setUsername("john_doe"); + config.setCreatedBy(creator); + + when(configService.findById(id)).thenReturn(Optional.of(config)); + + List> mockRecipients = List.of( + new HashMap<>(Map.of("phoneNumber", "+254999999999"))); + when(hydratorService.getRecipients(eq("tenant3"), any())).thenReturn(mockRecipients); + + DispatchRequest req = new DispatchRequest(); + req.setTemplateCode("ALERT"); + + ResponseEntity> res = buildController().triggerDispatch(id, req); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(1, res.getBody().get("queued")); + + verify(notificationService).queueTemplatedSms(any(), eq("john_doe")); + } +}