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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ name: CI/CD Pipeline

on:
pull_request:
branches: ['main']
branches: ["main"]
push:
branches: [develop]
tags-ignore:
- 'v*'
- "v*"

jobs:
PR:
Expand Down Expand Up @@ -42,8 +42,7 @@ jobs:
permissions:
contents: write
needs: CHECK_MESSAGE
# Only run if the PR commit message contains 'release'
if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') }}
if: ${{ contains(needs.CHECK_MESSAGE.outputs.sms, 'release') && github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: Checkout Source Branch
uses: actions/checkout@v4
Expand All @@ -56,8 +55,8 @@ jobs:
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
java-version: "17"
distribution: "temurin"
cache: gradle

- name: Grant execute permission for gradlew
Expand All @@ -67,38 +66,38 @@ jobs:
id: versioning
run: |
GRADLE_FILE="build.gradle"

# 1. Read current version
CURRENT_VERSION=$(grep "version = " $GRADLE_FILE | sed -E "s/.*version = '([0-9]+\.[0-9]+\.[0-9]+)'.*/\1/")
echo "Current Version: $CURRENT_VERSION"

# 2. Parse and Increment
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR=${VERSION_PARTS[0]}
MINOR=${VERSION_PARTS[1]}
PATCH=${VERSION_PARTS[2]}

PATCH=$((PATCH + 1))
if [ "$PATCH" -gt 99 ]; then
PATCH=0
MINOR=$((MINOR + 1))
fi

NEW_VERSION="$MAJOR.$MINOR.$PATCH"
echo "New Version: $NEW_VERSION"
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT

# 3. Update build.gradle
sed -i "s/version = '$CURRENT_VERSION'/version = '$NEW_VERSION'/" $GRADLE_FILE

# 4. Commit and Tag
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

git add $GRADLE_FILE
git commit -m "Release v$NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"

# 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 }}
Expand All @@ -119,17 +118,17 @@ jobs:
IMAGE_NAME: flexcodelabs/flextuma
run: |
echo "Building Docker image for version $VERSION..."

docker build --target prod \
-t $IMAGE_NAME:$VERSION \
-t $IMAGE_NAME:latest \
.

echo "Pushing images..."
docker push $IMAGE_NAME:$VERSION
docker push $IMAGE_NAME:latest

- name: 🔀
uses: BaharaJr/merge-pr@0.0.1
with:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN }}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Create a `.env` file in the root directory or export the variables in your shell
| `SMS_PRICE_PER_SEGMENT` | ❌ | `20.0` | Price per SMS segment (in TZS) |
| `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | ❌ | `60000` | Beem delivery-report polling interval in milliseconds |
| `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | ❌ | `5` | Minimum delay before the first Beem delivery lookup |
| `FLEXTUMA_ADMIN_SEED_PASSWORD` | ✅ (first boot only) | — | Password for the seeded `admin` account. Only required until that account exists; ignored on later restarts |
| `FLEXTUMA_SYSTEM_SEED_PASSWORD` | ✅ (first boot only) | — | Password for the seeded `SYSTEM` account. Only required until that account exists; ignored on later restarts |

### 3. Build the application

Expand Down Expand Up @@ -699,7 +701,7 @@ This is enforced in `BaseService.buildTenantSpec()` — all subclass services be

## 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.
On startup, `DataInitializer` runs `DataSeederService.seedSystemData()`, which issues the seeding SQL directly via `JdbcTemplate` to ensure system-level data (privileges, default roles, the `admin` and `SYSTEM` accounts) is present before the application accepts requests. The `admin`/`SYSTEM` accounts are only created on a deployment's first-ever boot and require `FLEXTUMA_ADMIN_SEED_PASSWORD`/`FLEXTUMA_SYSTEM_SEED_PASSWORD` to be set at that point — startup fails fast if either account doesn't exist yet and its password isn't configured.

---

Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.72'
version = '0.0.73'
description = 'Flextuma App'

java {
Expand Down
22 changes: 0 additions & 22 deletions seed.sql

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException;
import com.flexcodelabs.flextuma.core.services.DataSeederService;

import lombok.RequiredArgsConstructor;
Expand All @@ -23,6 +24,11 @@ public void run(String... args) {
log.info("🌱 FLEXTUMA: Calling seeder service...");
seederService.seedSystemData();
log.info("✅ FLEXTUMA: System seeding completed successfully!");
} catch (MissingSeedConfigurationException e) {
// Unlike other seeding failures below, this must abort startup: continuing would
// either boot with no admin account reachable, or silently skip creating one.
log.error("❌ FLEXTUMA: {}", e.getMessage());
throw e;
} catch (Exception e) {
log.error("❌ FLEXTUMA: System seeding failed: {}", e.getMessage(), e);
// Don't throw - allow application to continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import java.util.List;

/** Suggestions is empty when {@code available} is true -- there's nothing to suggest an
* alternative to. {@code emailAvailable} is null when no email was passed to the check. */
* alternative to. {@code emailAvailable}/{@code phoneAvailable} are null when no email/phone
* number was passed to the check. */
public record UsernameAvailabilityDto(String username, boolean available, List<String> suggestions,
Boolean emailAvailable) {
Boolean emailAvailable, Boolean phoneAvailable) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flexcodelabs.flextuma.core.dtos;

/** Request body for the username-availability check. {@code email} is optional. */
public record UsernameAvailabilityRequestDto(String username, String email) {
/** Request body for the username-availability check. {@code email} and {@code phoneNumber} are
* optional. */
public record UsernameAvailabilityRequestDto(String username, String email, String phoneNumber) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.flexcodelabs.flextuma.core.exceptions;

/** Thrown when a required seed-time secret (e.g. an initial account password) isn't configured.
* Unlike other seeding failures, this must abort startup rather than be logged and swallowed --
* silently continuing would boot the app with no way to log in, or worse, quietly skip creating
* the account at all. See DataInitializer, which re-throws this one specifically. */
public class MissingSeedConfigurationException extends RuntimeException {
public MissingSeedConfigurationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ public interface UserRepository extends JpaRepository<User, UUID>, JpaSpecificat
boolean existsByUsername(String username);

boolean existsByEmail(String email);

boolean existsByPhoneNumber(String phoneNumber);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
Expand All @@ -19,6 +22,15 @@ public class DataSeederService {
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;

// No default on purpose: a fresh deployment must supply these before its first boot, so a
// public checkout of this repo can never ship a working default credential. Once the
// account exists, later boots don't re-check the property (see seedUserIfAbsent).
@Value("${flextuma.admin-seed.password:}")
private String adminSeedPassword;

@Value("${flextuma.system-seed.password:}")
private String systemSeedPassword;

@Transactional
public void seedSystemData() {
log.info("🌱 Starting system data seeding...");
Expand Down Expand Up @@ -50,10 +62,12 @@ public void seedSystemData() {

seedReadPrivileges();

seedUser(roleId, "admin", "admin@flextuma.com", "Admin123", roleId);
seedUserIfAbsent(roleId, "admin", "admin@flextuma.com", adminSeedPassword,
roleId, "FLEXTUMA_ADMIN_SEED_PASSWORD");

seedUser(UUID.fromString("7269df24-68a0-4776-bd89-4015521bc19d"), "SYSTEM",
"system@flextuma.com", "system_secret_key", roleId);
seedUserIfAbsent(UUID.fromString("7269df24-68a0-4776-bd89-4015521bc19d"), "SYSTEM",
"system@flextuma.com", systemSeedPassword, roleId,
"FLEXTUMA_SYSTEM_SEED_PASSWORD");

log.info("✅✅✅ System seeding via JDBC completed successfully. ✅✅✅");
} catch (Exception e) {
Expand Down Expand Up @@ -81,6 +95,25 @@ private void seedReadPrivileges() {
});
}

/** Only the first-ever boot for a given userId needs the password: once the row exists,
* later restarts skip straight past the property check (ON CONFLICT already makes the
* insert itself idempotent, but checking here avoids demanding the env var forever). */
private void seedUserIfAbsent(UUID userId, String username, String email, String pass, UUID roleId,
String requiredEnvVarName) {
Boolean exists = jdbcTemplate.queryForObject(
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE id = ?)", Boolean.class, userId);
if (Boolean.TRUE.equals(exists)) {
log.info("👤 User {} already seeded, skipping.", username);
return;
}
if (pass == null || pass.isBlank()) {
throw new MissingSeedConfigurationException(
requiredEnvVarName + " must be set before the initial '" + username
+ "' account can be created.");
}
seedUser(userId, username, email, pass, roleId);
}

private void seedUser(UUID userId, String username, String email, String pass, UUID roleId) {
log.info("👤 Seeding user: {} ({})", username, email);
String hashedPass = passwordEncoder.encode(pass);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public class PublicUserController {
public ResponseEntity<UsernameAvailabilityDto> checkUsernameAvailability(
@RequestBody UsernameAvailabilityRequestDto request, HttpServletRequest httpRequest) {
rateLimitService.checkAndRecord(USERNAME_AVAILABILITY_BUCKET, httpRequest, maxRequestsPerWindow, windowSeconds);
return ResponseEntity.ok(userService.checkUsernameAvailability(request.username(), request.email()));
return ResponseEntity.ok(userService.checkUsernameAvailability(
request.username(), request.email(), request.phoneNumber()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ public User findByUsername(String username) {
* validation. When taken, suggests alternatives so the caller isn't left to guess one --
* built from the email's local part (before '@') when an email was passed and it's still
* free, since that's more likely to read as "theirs" than a random suffix. */
public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, String rawEmail) {
public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, String rawEmail,
String rawPhoneNumber) {
String username = rawUsername == null ? "" : rawUsername.trim();
if (username.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required");
Expand All @@ -171,6 +172,9 @@ public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, Str
}
}

String phoneNumber = rawPhoneNumber == null ? "" : rawPhoneNumber.trim();
Boolean phoneAvailable = phoneNumber.isBlank() ? null : !repository.existsByPhoneNumber(phoneNumber);

boolean available = !repository.existsByUsername(username);
List<String> suggestions;
if (available) {
Expand All @@ -181,7 +185,7 @@ public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, Str
: username;
suggestions = generateAvailableUsernames(suggestionBase);
}
return new UsernameAvailabilityDto(username, available, suggestions, emailAvailable);
return new UsernameAvailabilityDto(username, available, suggestions, emailAvailable, phoneAvailable);
}

private List<String> generateAvailableUsernames(String requested) {
Expand Down Expand Up @@ -214,6 +218,12 @@ public User register(RegisterDto request) {
"User with email " + request.getEmail() + " already exists");
});
}
if (request.getPhoneNumber() != null && !request.getPhoneNumber().isBlank()) {
repository.findByPhoneNumber(request.getPhoneNumber()).ifPresent(u -> {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"User with phone number " + request.getPhoneNumber() + " already exists");
});
}

User user = new User();
user.setName(request.getName());
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ flextuma.rate-limit.username-availability.window-seconds=${FLEXTUMA_RATE_LIMIT_U

# Base64-encoded 32-byte AES key. Required before connector credentials can be created or updated.
flextuma.connector-secrets.encryption-key=${FLEXTUMA_CONNECTOR_ENCRYPTION_KEY:}

# Passwords for the seeded 'admin' and 'SYSTEM' accounts. No default on purpose: required only
# for a deployment's first-ever boot (DataSeederService skips accounts that already exist), so
# startup fails fast rather than silently creating a well-known default credential.
flextuma.admin-seed.password=${FLEXTUMA_ADMIN_SEED_PASSWORD:}
flextuma.system-seed.password=${FLEXTUMA_SYSTEM_SEED_PASSWORD:}
# Set to 0 only for a deliberately unlimited plan. This cap applies before a shared system-connector message is charged.
flextuma.system-connectors.daily-message-limit-per-user=${FLEXTUMA_SYSTEM_CONNECTORS_DAILY_MESSAGE_LIMIT_PER_USER:1000}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.flexcodelabs.flextuma.core.config;

import com.flexcodelabs.flextuma.core.exceptions.MissingSeedConfigurationException;
import com.flexcodelabs.flextuma.core.services.DataSeederService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;

@ExtendWith(MockitoExtension.class)
class DataInitializerTest {

@Mock
private DataSeederService seederService;

@Test
void run_shouldPropagate_whenSeedConfigurationIsMissing() {
doThrow(new MissingSeedConfigurationException("FLEXTUMA_ADMIN_SEED_PASSWORD must be set"))
.when(seederService).seedSystemData();

DataInitializer initializer = new DataInitializer(seederService);

assertThrows(MissingSeedConfigurationException.class, () -> initializer.run());
}

@Test
void run_shouldSwallow_whenSeedingFailsForAnyOtherReason() {
doThrow(new RuntimeException("transient DB issue")).when(seederService).seedSystemData();

DataInitializer initializer = new DataInitializer(seederService);

assertDoesNotThrow(() -> initializer.run());
}
}
Loading