diff --git a/extensions/core/deployment/pom.xml b/extensions/core/deployment/pom.xml
index 08d75c9..a5b3044 100644
--- a/extensions/core/deployment/pom.xml
+++ b/extensions/core/deployment/pom.xml
@@ -17,6 +17,10 @@
dev.cloudeko
external-authentication-extension-deployment
+
+ dev.cloudeko
+ zenei-user-account-extension-deployment
+
io.quarkus
diff --git a/extensions/core/runtime/pom.xml b/extensions/core/runtime/pom.xml
index 18841e7..0627c7e 100644
--- a/extensions/core/runtime/pom.xml
+++ b/extensions/core/runtime/pom.xml
@@ -17,6 +17,10 @@
dev.cloudeko
external-authentication-extension
+
+ dev.cloudeko
+ zenei-user-account-extension
+
io.quarkus
diff --git a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java
index 1b1bfcf..2af03a0 100644
--- a/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java
+++ b/extensions/core/runtime/src/main/java/dev/cloudeko/zenei/extension/core/feature/impl/CreateUserImpl.java
@@ -12,6 +12,9 @@
import dev.cloudeko.zenei.extension.core.model.user.UserPassword;
import dev.cloudeko.zenei.extension.core.repository.UserPasswordRepository;
import dev.cloudeko.zenei.extension.core.repository.UserRepository;
+import dev.cloudeko.zenei.user.UserAccountManager;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccount;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccountManager;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import lombok.AllArgsConstructor;
@@ -33,6 +36,8 @@ public class CreateUserImpl implements CreateUser {
private final UserRepository userRepository;
private final UserPasswordRepository userPasswordRepository;
+ private final BasicUserAccountManager userAccountManager;
+
@Override
@Transactional
public User handle(CreateUserInput createUserInput) {
@@ -53,6 +58,8 @@ public User handle(CreateUserInput createUserInput) {
checkExistingUsername(user.getUsername());
checkExistingEmail(user.getPrimaryEmailAddress().getEmail());
+ userAccountManager.createUserBlocking(new BasicUserAccount(user.getUsername(), "test"));
+
userRepository.createUser(user);
if (createUserInput.isPasswordEnabled()) {
diff --git a/extensions/pom.xml b/extensions/pom.xml
index ffedcf0..febf1ef 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -17,6 +17,7 @@
external-authentication
core
+ zenei-user-account
rest
jdbc-panache
diff --git a/extensions/zenei-user-account/deployment/pom.xml b/extensions/zenei-user-account/deployment/pom.xml
new file mode 100644
index 0000000..131ad13
--- /dev/null
+++ b/extensions/zenei-user-account/deployment/pom.xml
@@ -0,0 +1,70 @@
+
+
+ 4.0.0
+
+ dev.cloudeko
+ zenei-user-account-extension-parent
+ 0.0.1
+
+
+ Zenei - Extensions - Rest - Deployment
+ zenei-user-account-extension-deployment
+
+
+
+ io.quarkus
+ quarkus-core-deployment
+
+
+ io.quarkus
+ quarkus-vertx-http-deployment
+
+
+ io.quarkus
+ quarkus-vertx-deployment
+
+
+ io.quarkus
+ quarkus-security-deployment
+
+
+ io.quarkus
+ quarkus-devservices-deployment
+
+
+ io.quarkus
+ quarkus-jsonp-deployment
+
+
+
+ dev.cloudeko
+ zenei-user-account-extension
+
+
+
+ io.quarkus
+ quarkus-junit5-internal
+ test
+
+
+
+
+
+ maven-compiler-plugin
+
+
+ ${maven.compiler.target}
+
+
+ io.quarkus
+ quarkus-extension-processor
+ ${quarkus.version}
+
+
+
+
+
+
+
diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java
new file mode 100644
index 0000000..3454cb5
--- /dev/null
+++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/BasicUserAccountManagerProcessor.java
@@ -0,0 +1,137 @@
+package dev.cloudeko.zenei.user.deployment;
+
+import dev.cloudeko.zenei.user.UserAccountRepositoryBase;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccountInitializer;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccountManager;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccountRecorder;
+import dev.cloudeko.zenei.user.runtime.BasicUserAccountRepository;
+import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
+import io.quarkus.arc.deployment.BeanContainerBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
+import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem;
+import io.quarkus.deployment.Capabilities;
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.annotations.Consume;
+import io.quarkus.deployment.annotations.Record;
+import io.quarkus.runtime.configuration.ConfigurationException;
+import jakarta.enterprise.context.Dependent;
+import jakarta.inject.Singleton;
+
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static io.quarkus.deployment.Capability.*;
+import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT;
+import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT;
+
+public class BasicUserAccountManagerProcessor {
+
+ private static final String[] SUPPORTED_REACTIVE_CLIENTS = new String[] { REACTIVE_PG_CLIENT, REACTIVE_MYSQL_CLIENT,
+ REACTIVE_MSSQL_CLIENT, REACTIVE_DB2_CLIENT, REACTIVE_ORACLE_CLIENT };
+
+ @BuildStep
+ @Record(STATIC_INIT)
+ SyntheticBeanBuildItem createDbTokenStateInitializerProps(BasicUserAccountRecorder recorder, Capabilities capabilities) {
+ final String reactiveClient = capabilities.getCapabilities().stream()
+ .filter(c -> Arrays.asList(SUPPORTED_REACTIVE_CLIENTS).contains(c))
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("No supported reactive SQL client found"));
+
+ final String createTableDdl;
+ final boolean supportsIfTableNotExists = switch (reactiveClient) {
+ case REACTIVE_PG_CLIENT -> {
+ createTableDdl = "CREATE TABLE IF NOT EXISTS zenei_user_account ("
+ + "id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
+ + "username VARCHAR(100) NOT NULL, "
+ + "image VARCHAR(1000) NULL, "
+ + "created_at TIMESTAMP NOT NULL, "
+ + "updated_at TIMESTAMP NOT NULL)";
+ yield true;
+ }
+ case REACTIVE_MYSQL_CLIENT -> {
+ createTableDdl = "CREATE TABLE IF NOT EXISTS zenei_user_account ("
+ + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+ + "username VARCHAR(100) NOT NULL, "
+ + "image VARCHAR(1000) NULL, "
+ + "created_at TIMESTAMP NOT NULL, "
+ + "updated_at TIMESTAMP NOT NULL)";
+ yield true;
+ }
+ case REACTIVE_MSSQL_CLIENT -> {
+ createTableDdl = "CREATE TABLE zenei_user_account ("
+ + "id BIGINT IDENTITY(1,1) PRIMARY KEY, "
+ + "username NVARCHAR(100) NOT NULL, "
+ + "image NVARCHAR(1000) NULL, "
+ + "created_at DATETIME2 NOT NULL, "
+ + "updated_at DATETIME2 NOT NULL)";
+ yield false; // MSSQL doesn't support `IF NOT EXISTS` directly
+ }
+ case REACTIVE_DB2_CLIENT -> {
+ createTableDdl = "CREATE TABLE zenei_user_account ("
+ + "id BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY (START WITH 1, INCREMENT BY 1), "
+ + "username VARCHAR(100) NOT NULL, "
+ + "image VARCHAR(1000), "
+ + "created_at TIMESTAMP NOT NULL, "
+ + "updated_at TIMESTAMP NOT NULL, "
+ + "PRIMARY KEY (id))";
+ yield false; // DB2 doesn't support `IF NOT EXISTS` directly
+ }
+ case REACTIVE_ORACLE_CLIENT -> {
+ createTableDdl = "CREATE TABLE zenei_user_account ("
+ + "id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "
+ + "username VARCHAR2(100) NOT NULL, "
+ + "image VARCHAR2(1000), "
+ + "created_at TIMESTAMP NOT NULL, "
+ + "updated_at TIMESTAMP NOT NULL)";
+ yield true;
+ }
+ default -> throw new ConfigurationException("Unknown Reactive Sql Client " + reactiveClient);
+ };
+
+ return SyntheticBeanBuildItem
+ .configure(BasicUserAccountInitializer.UserAccountInitializerProperties.class)
+ .supplier(recorder.createUserAccountInitializerProps(createTableDdl, supportsIfTableNotExists))
+ .unremovable()
+ .scope(Dependent.class)
+ .done();
+ }
+
+ @BuildStep
+ AdditionalBeanBuildItem createBasicUserAccountInitializerBean() {
+ return new AdditionalBeanBuildItem(BasicUserAccountInitializer.class);
+ }
+
+ @BuildStep
+ @Record(STATIC_INIT)
+ SyntheticBeanBuildItem syntheticBeanBuildItem(BasicUserAccountRecorder recorder, Capabilities capabilities) {
+ final String reactiveClient = capabilities.getCapabilities().stream()
+ .filter(c -> Arrays.asList(SUPPORTED_REACTIVE_CLIENTS).contains(c))
+ .findFirst()
+ .orElseThrow(() -> new RuntimeException("No supported reactive SQL client found"));
+
+ final Map config = Arrays.stream(DefaultQuery.values())
+ .collect(Collectors.toMap(defaultQuery -> defaultQuery.getMetadata().queryKey(),
+ defaultQuery -> defaultQuery.getMetadata().getQuery(reactiveClient)));
+
+ return SyntheticBeanBuildItem
+ .configure(BasicUserAccountRepository.class)
+ .addType(UserAccountRepositoryBase.class)
+ .unremovable()
+ .scope(Singleton.class)
+ .supplier(recorder.createBasicUserAccountRepository(config))
+ .done();
+ }
+
+ @BuildStep
+ AdditionalBeanBuildItem createBasicUserAccountManagerBean() {
+ return new AdditionalBeanBuildItem(BasicUserAccountManager.class);
+ }
+
+ @BuildStep
+ @Record(RUNTIME_INIT)
+ @Consume(SyntheticBeansRuntimeInitBuildItem.class)
+ void setSqlClientPool(BasicUserAccountRecorder recorder, BeanContainerBuildItem beanContainer) {
+ recorder.setSqlClientPool(beanContainer.getValue());
+ }
+}
diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java
new file mode 100644
index 0000000..b0ed0de
--- /dev/null
+++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/DefaultQuery.java
@@ -0,0 +1,51 @@
+package dev.cloudeko.zenei.user.deployment;
+
+import dev.cloudeko.zenei.user.QueryRegistry;
+
+public enum DefaultQuery {
+ USER_ACCOUNT_FIND_BY_IDENTIFIER(QueryMetadata.of(
+ "SELECT id, username, image, created_at, updated_at FROM user_account WHERE id = ?",
+ 1,
+ QueryRegistry.USER_ACCOUNT_FIND_BY_IDENTIFIER
+ )),
+ USER_ACCOUNT_FIND_BY_USERNAME(QueryMetadata.of(
+ "SELECT id, username, image, created_at, updated_at FROM user_account WHERE username = ?",
+ 1,
+ QueryRegistry.USER_ACCOUNT_FIND_BY_USERNAME
+ )),
+ USER_ACCOUNT_LIST(QueryMetadata.of(
+ "SELECT id, username, image, created_at, updated_at FROM user_account",
+ 0,
+ QueryRegistry.USER_ACCOUNT_LIST
+ )),
+ USER_ACCOUNT_LIST_PAGINATED(QueryMetadata.of(
+ "SELECT id, username, image, created_at, updated_at FROM user_account LIMIT ? OFFSET ?",
+ 2,
+ QueryRegistry.USER_ACCOUNT_LIST_PAGINATED
+ )),
+ USER_ACCOUNT_CREATE(QueryMetadata.of(
+ "INSERT INTO user_account (username, image, created_at, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id, username, image, created_at, updated_at",
+ 2,
+ QueryRegistry.USER_ACCOUNT_CREATE
+ )),
+ USER_ACCOUNT_UPDATE(QueryMetadata.of(
+ "UPDATE user_account SET username = ?, image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? RETURNING id, username, image, created_at, updated_at",
+ 3,
+ QueryRegistry.USER_ACCOUNT_UPDATE
+ )),
+ USER_ACCOUNT_DELETE(QueryMetadata.of(
+ "DELETE FROM user_account WHERE id = ? RETURNING id, username, image, created_at, updated_at",
+ 1,
+ QueryRegistry.USER_ACCOUNT_DELETE
+ ));
+
+ private final QueryMetadata metadata;
+
+ DefaultQuery(QueryMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ public QueryMetadata getMetadata() {
+ return metadata;
+ }
+}
diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java
new file mode 100644
index 0000000..5354c59
--- /dev/null
+++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/ExternalAuthBuildSteps.java
@@ -0,0 +1,23 @@
+package dev.cloudeko.zenei.user.deployment;
+
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.builditem.FeatureBuildItem;
+
+public class ExternalAuthBuildSteps {
+
+ private static final String FEATURE = "zenei-user-account";
+
+ @BuildStep
+ public FeatureBuildItem feature() {
+ return new FeatureBuildItem(FEATURE);
+ }
+
+ /*@BuildStep
+ public RouteBuildItem route(NonApplicationRootPathBuildItem nonApplicationRootPathBuildItem) {
+ return nonApplicationRootPathBuildItem.routeBuilder()
+ .route("user")
+ .handler(new UserInfoHandler())
+ .displayOnNotFoundPage()
+ .build();
+ }*/
+}
diff --git a/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java
new file mode 100644
index 0000000..a4a7554
--- /dev/null
+++ b/extensions/zenei-user-account/deployment/src/main/java/dev/cloudeko/zenei/user/deployment/QueryMetadata.java
@@ -0,0 +1,33 @@
+package dev.cloudeko.zenei.user.deployment;
+
+import io.quarkus.deployment.Capability;
+
+public record QueryMetadata(String query, int parameters, String queryKey) {
+
+ public QueryMetadata {
+ if (query == null || query.isBlank()) {
+ throw new IllegalArgumentException("Query cannot be null or empty");
+ }
+ if (queryKey == null || queryKey.isBlank()) {
+ throw new IllegalArgumentException("Query key cannot be null or empty");
+ }
+ }
+
+ public static QueryMetadata of(String query, int parameters, String queryKey) {
+ return new QueryMetadata(query, parameters, queryKey);
+ }
+
+ public String getQuery(String client) {
+ final String[] placeholders = new String[parameters];
+ for (int i = 0; i < parameters; i++) {
+ placeholders[i] = switch (client) {
+ case Capability.REACTIVE_PG_CLIENT -> "$" + (i + 1);
+ case Capability.REACTIVE_MSSQL_CLIENT -> "@p" + (i + 1);
+ case Capability.REACTIVE_DB2_CLIENT, Capability.REACTIVE_ORACLE_CLIENT, Capability.REACTIVE_MYSQL_CLIENT -> "?";
+ default -> throw new IllegalArgumentException("Unknown Reactive Sql Client " + client);
+ };
+ }
+
+ return String.format(query, (Object[]) placeholders);
+ }
+}
diff --git a/extensions/zenei-user-account/pom.xml b/extensions/zenei-user-account/pom.xml
new file mode 100644
index 0000000..5b3744e
--- /dev/null
+++ b/extensions/zenei-user-account/pom.xml
@@ -0,0 +1,20 @@
+
+
+ 4.0.0
+
+ dev.cloudeko
+ extensions
+ 0.0.1
+
+
+ Zenei - Extensions - User Account - Parent
+ zenei-user-account-extension-parent
+ pom
+
+
+ deployment
+ runtime
+
+
diff --git a/extensions/zenei-user-account/runtime/pom.xml b/extensions/zenei-user-account/runtime/pom.xml
new file mode 100644
index 0000000..8170fc0
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/pom.xml
@@ -0,0 +1,84 @@
+
+
+ 4.0.0
+
+ dev.cloudeko
+ zenei-user-account-extension-parent
+ 0.0.1
+
+
+ Zenei - Extensions - User Account - Runtime
+ zenei-user-account-extension
+
+
+
+ io.quarkus
+ quarkus-core
+
+
+ io.quarkus
+ quarkus-vertx
+
+
+ io.quarkus
+ quarkus-vertx-http
+
+
+ io.quarkus
+ quarkus-security
+
+
+ io.quarkus
+ quarkus-jsonp
+
+
+ jakarta.annotation
+ jakarta.annotation-api
+
+
+ io.vertx
+ vertx-sql-client
+
+
+
+
+
+
+ io.quarkus
+ quarkus-extension-maven-plugin
+ ${quarkus.version}
+
+
+ compile
+
+ extension-descriptor
+
+
+ ${project.groupId}:${project.artifactId}-deployment:${project.version}
+
+
+
+
+
+
+ maven-compiler-plugin
+
+
+ ${maven.compiler.target}
+
+ -parameters
+
+
+
+ io.quarkus
+ quarkus-extension-processor
+ ${quarkus.version}
+
+
+
+
+
+
+
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java
new file mode 100644
index 0000000..01bbcd0
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/QueryRegistry.java
@@ -0,0 +1,11 @@
+package dev.cloudeko.zenei.user;
+
+public class QueryRegistry {
+ public static final String USER_ACCOUNT_FIND_BY_IDENTIFIER = "user-account-find-by-identifier";
+ public static final String USER_ACCOUNT_FIND_BY_USERNAME = "user-account-find-by-username";
+ public static final String USER_ACCOUNT_LIST = "user-account-list";
+ public static final String USER_ACCOUNT_LIST_PAGINATED = "user-account-list-paginated";
+ public static final String USER_ACCOUNT_CREATE = "user-account-create";
+ public static final String USER_ACCOUNT_UPDATE = "user-account-update";
+ public static final String USER_ACCOUNT_DELETE = "user-account-delete";
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java
new file mode 100644
index 0000000..53a58ef
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccount.java
@@ -0,0 +1,78 @@
+package dev.cloudeko.zenei.user;
+
+import java.time.LocalDateTime;
+
+public abstract class UserAccount {
+
+ private ID id;
+ private String username;
+ private String image;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+ public UserAccount() {
+ }
+
+ public UserAccount(String username, String image) {
+ this(null, username, image);
+ }
+
+ public UserAccount(ID id, String username, String image) {
+ this(id, username, image, null, null);
+ }
+
+ public UserAccount(ID id, String username, String image, LocalDateTime createdAt,
+ LocalDateTime updatedAt) {
+ this.id = id;
+ this.username = username;
+ this.image = image;
+ this.createdAt = createdAt;
+ this.updatedAt = updatedAt;
+ }
+
+ public ID getId() {
+ return id;
+ }
+
+ public void setId(ID id) {
+ this.id = id;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getImage() {
+ return image;
+ }
+
+ public void setImage(String image) {
+ this.image = image;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public LocalDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(LocalDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public record EmailAddress(String email, boolean verified, boolean primary) {
+ }
+
+ public record PhoneNumber(String number, boolean verified, boolean primary) {
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java
new file mode 100644
index 0000000..1ff3176
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountEmailAddressRepositoryBase.java
@@ -0,0 +1,4 @@
+package dev.cloudeko.zenei.user;
+
+public interface UserAccountEmailAddressRepositoryBase {
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java
new file mode 100644
index 0000000..80f269b
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountManager.java
@@ -0,0 +1,63 @@
+package dev.cloudeko.zenei.user;
+
+import io.smallrye.mutiny.Uni;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UserAccountManager {
+
+ Uni findUserByIdentifier(ID id);
+
+ default Optional findUserByIdentifierBlocking(ID id) {
+ return Optional.ofNullable(findUserByIdentifier(id).await().indefinitely());
+ }
+
+ Uni findUserByPrimaryEmailAddress(String email);
+
+ default Optional findUserByPrimaryEmailAddressBlocking(String email) {
+ return Optional.ofNullable(findUserByPrimaryEmailAddress(email).await().indefinitely());
+ }
+
+ Uni findUserByPrimaryPhoneNumber(String phoneNumber);
+
+ default Optional findUserByPrimaryPhoneNumberBlocking(String phoneNumber) {
+ return Optional.ofNullable(findUserByPrimaryPhoneNumber(phoneNumber).await().indefinitely());
+ }
+
+ Uni findUserByUsername(String username);
+
+ default Optional findUserByUsernameBlocking(String username) {
+ return Optional.ofNullable(findUserByUsername(username).await().indefinitely());
+ }
+
+ Uni> listUsers();
+
+ default List listUsersBlocking() {
+ return listUsers().await().indefinitely();
+ }
+
+ Uni> listUsers(int page, int pageSize);
+
+ default List listUsersBlocking(int page, int pageSize) {
+ return listUsers(page, pageSize).await().indefinitely();
+ }
+
+ Uni createUser(ENTITY entity);
+
+ default ENTITY createUserBlocking(ENTITY entity) {
+ return createUser(entity).await().indefinitely();
+ }
+
+ Uni updateUser(ENTITY entity);
+
+ default ENTITY updateUserBlocking(ENTITY entity) {
+ return updateUser(entity).await().indefinitely();
+ }
+
+ Uni deleteUser(ENTITY entity);
+
+ default boolean deleteUserBlocking(ENTITY entity) {
+ return deleteUser(entity).await().indefinitely();
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java
new file mode 100644
index 0000000..54a2411
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountPhoneNumberRepositoryBase.java
@@ -0,0 +1,4 @@
+package dev.cloudeko.zenei.user;
+
+public interface UserAccountPhoneNumberRepositoryBase {
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java
new file mode 100644
index 0000000..47cf558
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/UserAccountRepositoryBase.java
@@ -0,0 +1,22 @@
+package dev.cloudeko.zenei.user;
+
+import io.smallrye.mutiny.Uni;
+
+import java.util.List;
+
+public interface UserAccountRepositoryBase {
+
+ Uni findUserByIdentifier(ID identifier);
+
+ Uni findUserByUsername(String username);
+
+ Uni> listUsers();
+
+ Uni> listUsers(int page, int pageSize);
+
+ Uni createUser(ENTITY entity);
+
+ Uni updateUser(ENTITY entity);
+
+ Uni deleteUser(ENTITY entity);
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java
new file mode 100644
index 0000000..7ffd501
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccount.java
@@ -0,0 +1,23 @@
+package dev.cloudeko.zenei.user.runtime;
+
+import dev.cloudeko.zenei.user.UserAccount;
+import io.vertx.sqlclient.Row;
+
+public class BasicUserAccount extends UserAccount {
+
+ public BasicUserAccount() {
+ super();
+ }
+
+ public BasicUserAccount(String username, String image) {
+ super(username, image);
+ }
+
+ public BasicUserAccount(Row row) {
+ super(row.getLong("id"),
+ row.getString("username"),
+ row.getString("image"),
+ row.getLocalDateTime("created_at"),
+ row.getLocalDateTime("updated_at"));
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java
new file mode 100644
index 0000000..327ffbc
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountInitializer.java
@@ -0,0 +1,58 @@
+package dev.cloudeko.zenei.user.runtime;
+
+import io.quarkus.runtime.StartupEvent;
+import io.smallrye.mutiny.Uni;
+import io.vertx.core.Vertx;
+import io.vertx.sqlclient.Pool;
+import jakarta.enterprise.event.Observes;
+import org.jboss.logging.Logger;
+
+public class BasicUserAccountInitializer {
+
+ private static final Logger log = Logger.getLogger(BasicUserAccountInitializer.class);
+ private static final String FAILED_TO_CREATE_DB_TABLE = "unknown reason, please report the issue and create table manually";
+
+ void initialize(@Observes StartupEvent event, Vertx vertx, Pool pool, UserAccountInitializerProperties initializerProps) {
+ createDatabaseTable(pool, initializerProps.createTableDdl, initializerProps.supportsIfTableNotExists);
+ }
+
+ private static void createDatabaseTable(Pool pool, String createTableDdl, boolean supportsIfTableNotExists) {
+ log.debugf("Creating database table with query: %s", createTableDdl);
+
+ Uni tableCreationResult = Uni.createFrom()
+ .completionStage(pool.query(createTableDdl).execute().toCompletionStage())
+ .onItemOrFailure()
+ .transformToUni((rows, throwable) -> {
+ if (throwable != null) {
+ return supportsIfTableNotExists
+ ? Uni.createFrom().item(throwable.getMessage())
+ : Uni.createFrom().nullItem();
+ }
+
+ return verifyTableExists(pool);
+ });
+
+ String errMsg = tableCreationResult.await().indefinitely();
+ if (errMsg != null) {
+ throw new RuntimeException("OIDC Token State Manager failed to create database table: " + errMsg);
+ }
+ }
+
+ private static Uni verifyTableExists(Pool pool) {
+ return Uni.createFrom()
+ .completionStage(pool.query("SELECT MAX(id) FROM zenei_user_account").execute().toCompletionStage())
+ .map(rows -> {
+ if (rows != null && rows.columnsNames().size() == 1) {
+ return null; // Table exists
+ }
+ return FAILED_TO_CREATE_DB_TABLE;
+ })
+ .onFailure().recoverWithItem(throwable -> {
+ log.error("Create database query failed with: ", throwable);
+ return FAILED_TO_CREATE_DB_TABLE;
+ });
+ }
+
+ public record UserAccountInitializerProperties(String createTableDdl, boolean supportsIfTableNotExists) {
+ }
+}
\ No newline at end of file
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java
new file mode 100644
index 0000000..d7b30a5
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountManager.java
@@ -0,0 +1,64 @@
+package dev.cloudeko.zenei.user.runtime;
+
+import dev.cloudeko.zenei.user.UserAccountManager;
+import dev.cloudeko.zenei.user.UserAccountRepositoryBase;
+import io.smallrye.mutiny.Uni;
+import org.jboss.logging.Logger;
+
+import java.util.List;
+
+public class BasicUserAccountManager implements UserAccountManager {
+
+ private static final Logger log = Logger.getLogger(BasicUserAccountManager.class);
+
+ private final UserAccountRepositoryBase userAccountRepository;
+
+ public BasicUserAccountManager(UserAccountRepositoryBase userAccountRepository) {
+ this.userAccountRepository = userAccountRepository;
+ }
+
+ @Override
+ public Uni findUserByIdentifier(Long identifier) {
+ return userAccountRepository.findUserByIdentifier(identifier);
+ }
+
+ @Override
+ public Uni findUserByPrimaryEmailAddress(String email) {
+ return null;
+ }
+
+ @Override
+ public Uni findUserByPrimaryPhoneNumber(String phoneNumber) {
+ return null;
+ }
+
+ @Override
+ public Uni findUserByUsername(String username) {
+ return userAccountRepository.findUserByUsername(username);
+ }
+
+ @Override
+ public Uni> listUsers() {
+ return userAccountRepository.listUsers();
+ }
+
+ @Override
+ public Uni> listUsers(int page, int pageSize) {
+ return userAccountRepository.listUsers(page, pageSize);
+ }
+
+ @Override
+ public Uni createUser(BasicUserAccount basicUserAccount) {
+ return userAccountRepository.createUser(basicUserAccount);
+ }
+
+ @Override
+ public Uni updateUser(BasicUserAccount basicUserAccount) {
+ return userAccountRepository.updateUser(basicUserAccount);
+ }
+
+ @Override
+ public Uni deleteUser(BasicUserAccount basicUserAccount) {
+ return userAccountRepository.deleteUser(basicUserAccount);
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java
new file mode 100644
index 0000000..9b912ac
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRecorder.java
@@ -0,0 +1,26 @@
+package dev.cloudeko.zenei.user.runtime;
+
+import io.quarkus.arc.runtime.BeanContainer;
+import io.quarkus.runtime.annotations.Recorder;
+import io.vertx.sqlclient.Pool;
+
+import java.util.Map;
+import java.util.function.Supplier;
+
+@Recorder
+public class BasicUserAccountRecorder {
+
+ public void setSqlClientPool(BeanContainer container) {
+ container.beanInstance(BasicUserAccountRepository.class).setSqlClientPool(container.beanInstance(Pool.class));
+ }
+
+ public Supplier createBasicUserAccountRepository(Map config) {
+ return () -> new BasicUserAccountRepository(config);
+ }
+
+ public Supplier createUserAccountInitializerProps(
+ String createTableDdl,
+ boolean supportsIfTableNotExists) {
+ return () -> new BasicUserAccountInitializer.UserAccountInitializerProperties(createTableDdl, supportsIfTableNotExists);
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java
new file mode 100644
index 0000000..c69dae1
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/java/dev/cloudeko/zenei/user/runtime/BasicUserAccountRepository.java
@@ -0,0 +1,132 @@
+package dev.cloudeko.zenei.user.runtime;
+
+import dev.cloudeko.zenei.user.QueryRegistry;
+import dev.cloudeko.zenei.user.UserAccountRepositoryBase;
+import io.smallrye.mutiny.Multi;
+import io.smallrye.mutiny.Uni;
+import io.vertx.sqlclient.*;
+import org.jboss.logging.Logger;
+
+import java.util.List;
+import java.util.Map;
+
+public class BasicUserAccountRepository implements UserAccountRepositoryBase {
+
+ private static final Logger log = Logger.getLogger(BasicUserAccountRepository.class);
+ private static final String FAILED_TO_FIND_USER_BY_IDENTIFIER = "Failed to find user by identifier";
+
+ private final Map queries;
+ private Pool pool;
+
+ public BasicUserAccountRepository(Map queries) {
+ this.queries = queries;
+ }
+
+ public void setSqlClientPool(Pool pool) {
+ this.pool = pool;
+ }
+
+ @Override
+ public Uni findUserByIdentifier(Long identifier) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_FIND_BY_IDENTIFIER))
+ .execute(Tuple.of(identifier))
+ .toCompletionStage())
+ .onItem().transformToUni(this::processNullableRow)
+ .onItem().ifNotNull().transform(BasicUserAccount::new)
+ .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable));
+ }
+
+ @Override
+ public Uni findUserByUsername(String username) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_FIND_BY_USERNAME))
+ .execute(Tuple.of(username))
+ .toCompletionStage())
+ .onItem().transformToUni(this::processNullableRow)
+ .onItem().ifNotNull().transform(BasicUserAccount::new)
+ .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable));
+ }
+
+ @Override
+ public Uni> listUsers() {
+ return Uni.createFrom()
+ .completionStage(pool
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_LIST))
+ .execute()
+ .toCompletionStage())
+ .onItem().transformToUni(rows -> Uni.createFrom().item(rows)
+ .onItem().transformToMulti(rowSet -> Multi.createFrom().iterable(rowSet))
+ .onItem().transform(BasicUserAccount::new)
+ .collect().asList())
+ .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable));
+ }
+
+ @Override
+ public Uni> listUsers(int page, int pageSize) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_LIST_PAGINATED))
+ .execute(Tuple.of(page, pageSize))
+ .toCompletionStage())
+ .onItem().transformToUni(rows -> Uni.createFrom().item(rows)
+ .onItem().transformToMulti(rowSet -> Multi.createFrom().iterable(rowSet))
+ .onItem().transform(BasicUserAccount::new)
+ .collect().asList())
+ .onFailure().invoke(throwable -> log.error(FAILED_TO_FIND_USER_BY_IDENTIFIER, throwable));
+ }
+
+ @Override
+ public Uni createUser(BasicUserAccount basicUserAccount) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .withTransaction(client -> client
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_CREATE))
+ .execute(Tuple.of(basicUserAccount.getUsername(), basicUserAccount.getImage())))
+ .toCompletionStage())
+ .onItem().transformToUni(this::processNullableRow)
+ .onItem().ifNotNull().transform(BasicUserAccount::new)
+ .onFailure().transform(throwable -> new RuntimeException("Failed to create user", throwable));
+ }
+
+ @Override
+ public Uni updateUser(BasicUserAccount basicUserAccount) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .withTransaction(client -> client
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_UPDATE))
+ .execute(Tuple.of(basicUserAccount.getUsername(), basicUserAccount.getImage(),
+ basicUserAccount.getId())))
+ .toCompletionStage())
+ .onItem().transformToUni(this::processNullableRow)
+ .onItem().ifNotNull().transform(BasicUserAccount::new)
+ .onFailure().transform(throwable -> new RuntimeException("Failed to update user", throwable));
+ }
+
+ @Override
+ public Uni deleteUser(BasicUserAccount basicUserAccount) {
+ return Uni.createFrom()
+ .completionStage(pool
+ .withTransaction(client -> client
+ .preparedQuery(queries.get(QueryRegistry.USER_ACCOUNT_DELETE))
+ .execute(Tuple.of(basicUserAccount.getId())))
+ .toCompletionStage())
+ .onItem().transformToUni(rows -> Uni.createFrom().item(rows.rowCount() == 1))
+ .onFailure().transform(throwable -> new RuntimeException("Failed to delete user", throwable));
+ }
+
+ private Uni processNullableRow(RowSet rows) {
+ if (rows.size() == 0) {
+ return Uni.createFrom().nullItem();
+ }
+
+ final RowIterator iterator = rows.iterator();
+ if (iterator.hasNext()) {
+ return Uni.createFrom().item(iterator.next());
+ }
+
+ return Uni.createFrom().nullItem();
+ }
+}
diff --git a/extensions/zenei-user-account/runtime/src/main/resources/META-INF/beans.xml b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/beans.xml
new file mode 100644
index 0000000..e69de29
diff --git a/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml
new file mode 100644
index 0000000..cedb67d
--- /dev/null
+++ b/extensions/zenei-user-account/runtime/src/main/resources/META-INF/quarkus-extension.yaml
@@ -0,0 +1,9 @@
+name: Zenei Quarkus Rest Extension
+#description: Do something useful.
+metadata:
+# keywords:
+# - "key-auth"
+# guide: ... # To create and publish this guide, see https://github.com/quarkiverse/quarkiverse/wiki#documenting-your-extension
+# categories:
+# - "miscellaneous"
+# status: "preview"
diff --git a/platform/pom.xml b/platform/pom.xml
index 51aaafb..4665cfb 100644
--- a/platform/pom.xml
+++ b/platform/pom.xml
@@ -76,6 +76,10 @@
io.quarkus
quarkus-jdbc-postgresql
+
+ io.quarkus
+ quarkus-reactive-pg-client
+
io.quarkus
quarkus-hibernate-validator
diff --git a/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java b/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java
index a612af2..39bdac5 100644
--- a/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java
+++ b/platform/src/test/java/dev/cloudeko/zenei/resource/AbstractMockAuthorizationServerTestResource.java
@@ -3,7 +3,9 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
+import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.ConfigProvider;
diff --git a/pom.xml b/pom.xml
index f2a0659..96fd2a9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -72,6 +72,17 @@
${project.version}
+
+ dev.cloudeko
+ zenei-user-account-extension
+ ${project.version}
+
+
+ dev.cloudeko
+ zenei-user-account-extension-deployment
+ ${project.version}
+
+
dev.cloudeko
rest-extension