diff --git a/server/src/main/kotlin/app/accrescent/server/Main.kt b/server/src/main/kotlin/app/accrescent/server/Main.kt index ec679cb5..619f10f7 100644 --- a/server/src/main/kotlin/app/accrescent/server/Main.kt +++ b/server/src/main/kotlin/app/accrescent/server/Main.kt @@ -62,6 +62,7 @@ fun main() { ) val consoleApi = ConsoleApiImpl( dataStore = dataStore, + idGenerator = IdGenerator(randomSource), timestampSource = timestampSource, adminExternalUserId = config.authz.adminExternalUserId, ) diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/MemoryDataStore.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/MemoryDataStore.kt index ada4f8e3..22f5204c 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/MemoryDataStore.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/MemoryDataStore.kt @@ -111,6 +111,28 @@ class MemoryDataStore(vertx: Vertx) : DataStore { * @property connection the connection the transaction is running on. */ private class MemoryTransaction(private val connection: SqlConnection) : DataStore.Transaction() { + override suspend fun countAppDraftsInOrg( + organizationId: String, + ): DataStoreResult = either { + connection + .executeSingleQuery( + "SELECT COUNT(*) FROM app_drafts WHERE organization_id = ?", + Tuple.of(organizationId), + ) + .requireNonNegativeLong(0) + } + + override suspend fun createAppDraft( + appDraftId: String, + organizationId: String, + createTime: OffsetDateTime, + ): DataStoreResult = either { + connection.executeSingleUpdate( + "INSERT INTO app_drafts (id, organization_id, create_time) VALUES (?, ?, ?)", + Tuple.of(appDraftId, organizationId, createTime), + ) + } + override suspend fun createOrgWithOwner( organizationId: String, userId: String, @@ -163,15 +185,22 @@ private class MemoryTransaction(private val connection: SqlConnection) : DataSto organizationId: String, ): DataStoreResult> = either { connection.executeOptionalQuery( - "SELECT id FROM organizations WHERE id = ?", + """ + SELECT + id, + (SELECT COUNT(*) FROM app_drafts WHERE organization_id = organizations.id) + FROM organizations + WHERE id = ? + """.trimIndent(), Tuple.of(organizationId), ) .map { - // Apps and app drafts aren't stored yet, so organizations never have any OrgApiView( id = it.requireValue(0), + // Apps aren't stored yet, so organizations never have any published publishedAppCount = NonNegativeLong.MIN_VALUE, - activeAppDraftCount = NonNegativeLong.MIN_VALUE, + // App drafts can't be reviewed yet, so all of them are active + activeAppDraftCount = it.requireNonNegativeLong(1), ) } } @@ -215,6 +244,7 @@ private class MemoryTransaction(private val connection: SqlConnection) : DataSto request: HasPermissionRequest, ): DataStoreResult = either { val sql = when (request) { + is HasPermissionRequest.CreateAppDraft, is HasPermissionRequest.ViewOrganization -> "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND owner_user_id = ?)" } diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/PostgresqlDataStore.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/PostgresqlDataStore.kt index aa93895e..2e8b8772 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/PostgresqlDataStore.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/PostgresqlDataStore.kt @@ -153,6 +153,28 @@ class PostgresqlDataStore(vertx: Vertx, config: DataStoreConfig.Postgresql) : Da private class PostgresqlTransaction( private val connection: SqlConnection, ) : DataStore.Transaction() { + override suspend fun countAppDraftsInOrg( + organizationId: String, + ): DataStoreResult = either { + connection + .executeSingleQuery( + "SELECT COUNT(*) FROM app_drafts WHERE organization_id = ?", + Tuple.of(organizationId), + ) + .requireNonNegativeLong(0) + } + + override suspend fun createAppDraft( + appDraftId: String, + organizationId: String, + createTime: OffsetDateTime, + ): DataStoreResult = either { + connection.executeSingleUpdate( + "INSERT INTO app_drafts (id, organization_id, create_time) VALUES (?, ?, ?)", + Tuple.of(appDraftId, organizationId, createTime), + ) + } + override suspend fun createOrgWithOwner( organizationId: String, userId: String, @@ -209,15 +231,22 @@ private class PostgresqlTransaction( organizationId: String, ): DataStoreResult> = either { connection.executeOptionalQuery( - "SELECT id FROM organizations WHERE id = ?", + """ + SELECT + id, + (SELECT COUNT(*) FROM app_drafts WHERE organization_id = organizations.id) + FROM organizations + WHERE id = ? + """.trimIndent(), Tuple.of(organizationId), ) .map { - // Apps and app drafts aren't stored yet, so organizations never have any OrgApiView( id = it.requireValue(0), + // Apps aren't stored yet, so organizations never have any published publishedAppCount = NonNegativeLong.MIN_VALUE, - activeAppDraftCount = NonNegativeLong.MIN_VALUE, + // App drafts can't be reviewed yet, so all of them are active + activeAppDraftCount = it.requireNonNegativeLong(1), ) } } @@ -261,6 +290,7 @@ private class PostgresqlTransaction( request: HasPermissionRequest, ): DataStoreResult = either { val sql = when (request) { + is HasPermissionRequest.CreateAppDraft, is HasPermissionRequest.ViewOrganization -> "SELECT EXISTS(SELECT 1 FROM organizations WHERE id = ? AND owner_user_id = ?)" } diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/RowExt.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/RowExt.kt index 3f7eb95e..ff5aa0fb 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/RowExt.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driven/datastore2/vertx/RowExt.kt @@ -5,6 +5,7 @@ package app.accrescent.server.adapters.driven.datastore2.vertx import app.accrescent.server.core.Error +import app.accrescent.server.core.NonNegativeLong import app.accrescent.server.core.causedBy import app.accrescent.server.core.downcast import app.accrescent.server.core.toEitherBind @@ -31,6 +32,24 @@ inline fun Row.requireValue(columnIndex: Int): T { } } +/** + * Gets a required row value of SQL type `bigint` as a [NonNegativeLong]. + * + * Raises [DataStoreError.IllegalState] if the fetched value is `null`, not of type [Long], or + * negative. + * + * @return the value fetched from the database. + */ +context(_: Raise) +fun Row.requireNonNegativeLong(columnIndex: Int): NonNegativeLong { + val value = requireValue(columnIndex) + + return NonNegativeLong.new(value).toEitherBind { + DataStoreError.IllegalState() + .causedBy(ColumnValueOutOfRange(columnIndex, value, NonNegativeLong::class)) + } +} + /** * Gets an optional row value. * @@ -51,6 +70,19 @@ inline fun Row.getSafeValue(columnIndex: Int): Option { } } +/** + * A row value was out of the range of the type it was converted to. + * + * @param columnIndex the index of the column the value was fetched from. + * @param value the value fetched from the database. + * @param target the type the value was converted to. + */ +class ColumnValueOutOfRange(columnIndex: Int, value: Long, target: KClass<*>) : Error() { + override val message = + "expected column $columnIndex to be in the range of type ${target.qualifiedName}, but " + + "it was $value" +} + /** * A row value was of a different type than expected. * diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/AppDraftServiceImpl.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/AppDraftServiceImpl.kt index 5f577324..90993af4 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/AppDraftServiceImpl.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/AppDraftServiceImpl.kt @@ -4,9 +4,33 @@ package app.accrescent.server.adapters.driving.api.console.grpc +import app.accrescent.server.domain.ports.driving.api.console2.ConsoleApi +import arrow.core.getOrElse import build.buf.gen.accrescent.console.v1.AppDraftServiceGrpcKt +import build.buf.gen.accrescent.console.v1.CreateAppDraftRequest import kotlin.coroutines.CoroutineContext +import build.buf.gen.accrescent.console.v1.CreateAppDraftResponse as CreateAppDraftResponseProto +/** + * gRPC implementation of the console API's `AppDraftService`. + * + * Calls expect to be intercepted by [CallContextInterceptor]. + * + * @param context the coroutine context to run method implementations in. + * @param consoleApi the console API to serve. + */ class AppDraftServiceImpl( context: CoroutineContext, -) : AppDraftServiceGrpcKt.AppDraftServiceCoroutineImplBase(context) + private val consoleApi: ConsoleApi, +) : AppDraftServiceGrpcKt.AppDraftServiceCoroutineImplBase(context) { + override suspend fun createAppDraft( + request: CreateAppDraftRequest, + ): CreateAppDraftResponseProto { + val context = CallContextInterceptor.CALL_CONTEXT_KEY.get() + + return consoleApi.createAppDraft(context, toDomain(request)) + .mapLeft(::toStatus) + .getOrElse { throw it.asRuntimeException() } + .let(::toProto) + } +} diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConverters.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConverters.kt index 435172c3..eb9599c2 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConverters.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConverters.kt @@ -4,6 +4,8 @@ package app.accrescent.server.adapters.driving.api.console.grpc +import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftError import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationError import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationError import app.accrescent.server.domain.ports.driving.api.console2.GetSelfError @@ -12,6 +14,15 @@ import app.accrescent.server.domain.ports.driving.api.console2.ServerError import app.accrescent.server.domain.ports.driving.api.console2.UnauthenticatedError import io.grpc.Status +fun toStatus(error: CreateAppDraftError): Status { + return when (error) { + is UnauthenticatedError -> Status.UNAUTHENTICATED + is InsufficientPermissionError -> Status.PERMISSION_DENIED + is ActiveAppDraftLimitExceededError -> Status.RESOURCE_EXHAUSTED + is ServerError -> toStatus(error) + } +} + fun toStatus(error: GetMyOrganizationError): Status { return when (error) { is UnauthenticatedError -> Status.UNAUTHENTICATED diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConverters.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConverters.kt index d259f3e2..95c5dd2d 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConverters.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConverters.kt @@ -4,26 +4,41 @@ package app.accrescent.server.adapters.driving.api.console.grpc +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftResponse import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationRequest import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationResponse import app.accrescent.server.domain.ports.driving.api.console2.GetSelfResponse import app.accrescent.server.domain.ports.driving.api.console2.UserRole +import build.buf.gen.accrescent.console.v1.createAppDraftResponse import build.buf.gen.accrescent.console.v1.getMyOrganizationResponse import build.buf.gen.accrescent.console.v1.getOrganizationResponse import build.buf.gen.accrescent.console.v1.getSelfResponse import build.buf.gen.accrescent.console.v1.organization import build.buf.gen.accrescent.console.v1.user +import build.buf.gen.accrescent.console.v1.CreateAppDraftRequest as CreateAppDraftRequestProto +import build.buf.gen.accrescent.console.v1.CreateAppDraftResponse as CreateAppDraftResponseProto import build.buf.gen.accrescent.console.v1.GetMyOrganizationResponse as GetMyOrganizationResponseProto import build.buf.gen.accrescent.console.v1.GetOrganizationRequest as GetOrganizationRequestProto import build.buf.gen.accrescent.console.v1.GetOrganizationResponse as GetOrganizationResponseProto import build.buf.gen.accrescent.console.v1.GetSelfResponse as GetSelfResponseProto import build.buf.gen.accrescent.console.v1.UserRole as UserRoleProto +fun toDomain(request: CreateAppDraftRequestProto): CreateAppDraftRequest { + return CreateAppDraftRequest(request.organizationId) +} + fun toDomain(request: GetOrganizationRequestProto): GetOrganizationRequest { return GetOrganizationRequest(request.organizationId) } +fun toProto(response: CreateAppDraftResponse): CreateAppDraftResponseProto { + return createAppDraftResponse { + appDraftId = response.appDraftId + } +} + fun toProto(response: GetMyOrganizationResponse): GetMyOrganizationResponseProto { return getMyOrganizationResponse { organizationId = response.organizationId diff --git a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServer.kt b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServer.kt index e5670613..f21a6a67 100644 --- a/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServer.kt +++ b/server/src/main/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServer.kt @@ -70,7 +70,7 @@ private fun createServer( ): GrpcServer { val options = grpcServerOptions(GrpcProtocol.WEB, WireFormat.PROTOBUF) val server = GrpcIoServer.server(vertx, options) - .addInterceptedService(AppDraftServiceImpl(context)) + .addInterceptedService(AppDraftServiceImpl(context, consoleApi)) .addInterceptedService(AppEditServiceImpl(context)) .addInterceptedService(AppServiceImpl(context)) .addInterceptedService(OrganizationServiceImpl(context, consoleApi)) diff --git a/server/src/main/kotlin/app/accrescent/server/core/NonNegativeLong.kt b/server/src/main/kotlin/app/accrescent/server/core/NonNegativeLong.kt index f234472c..dc2f1a8b 100644 --- a/server/src/main/kotlin/app/accrescent/server/core/NonNegativeLong.kt +++ b/server/src/main/kotlin/app/accrescent/server/core/NonNegativeLong.kt @@ -14,7 +14,7 @@ import arrow.core.Some * @property value the underlying [Long] representation of this value. */ @JvmInline -value class NonNegativeLong private constructor(val value: Long) { +value class NonNegativeLong private constructor(val value: Long) : Comparable { companion object { val MIN_VALUE = NonNegativeLong(0) @@ -26,4 +26,8 @@ value class NonNegativeLong private constructor(val value: Long) { } } } + + override fun compareTo(other: NonNegativeLong): Int { + return value.compareTo(other.value) + } } diff --git a/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImpl.kt b/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImpl.kt index 119ea49e..e065acc4 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImpl.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImpl.kt @@ -7,12 +7,18 @@ package app.accrescent.server.domain.api.console2 import app.accrescent.server.core.NonNegativeLong import app.accrescent.server.core.bindMapLeft import app.accrescent.server.core.unwrap +import app.accrescent.server.domain.IdGenerator +import app.accrescent.server.domain.IdType import app.accrescent.server.domain.authn.ExternalUserId import app.accrescent.server.domain.ports.driven.datastore2.DataStore import app.accrescent.server.domain.ports.driven.datastore2.HasPermissionRequest import app.accrescent.server.domain.ports.driven.timestampsource.TimestampSource +import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError import app.accrescent.server.domain.ports.driving.api.console2.CallContext import app.accrescent.server.domain.ports.driving.api.console2.ConsoleApi +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftError +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftResponse import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationError import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationError @@ -33,14 +39,50 @@ import arrow.core.raise.ensure * Primary implementation of [ConsoleApi]. * * @param dataStore the data store to use for data persistence. + * @param idGenerator the generator of resource IDs. * @param timestampSource the source of timestamps to use. * @param adminExternalUserId the external identity of the sole administrator user. */ class ConsoleApiImpl( private val dataStore: DataStore, + private val idGenerator: IdGenerator, private val timestampSource: TimestampSource, private val adminExternalUserId: ExternalUserId, ) : ConsoleApi { + override suspend fun createAppDraft( + context: CallContext, + request: CreateAppDraftRequest, + ): Either = either { + val currentTime = timestampSource.now() + val appDraftId = dataStore.runInTransaction { tx -> + val userId = authenticateCaller(tx, context.sessionId, currentTime) + + val hasPermission = tx + .hasPermission(HasPermissionRequest.CreateAppDraft(request.organizationId, userId)) + .bindMapLeft(::toServerError) + ensure(hasPermission, ::InsufficientPermissionError) + + // App drafts can't be reviewed yet, so all of them are active + val activeAppDraftCount = tx + .countAppDraftsInOrg(request.organizationId) + .bindMapLeft(::toServerError) + ensure( + activeAppDraftCount < ACTIVE_APP_DRAFT_LIMIT, + ::ActiveAppDraftLimitExceededError, + ) + + val appDraftId = idGenerator.generateId(IdType.APP_DRAFT).bindMapLeft(::toServerError) + tx + .createAppDraft(appDraftId, request.organizationId, currentTime) + .bindMapLeft(::toServerError) + appDraftId + } + .bindMapLeft(::toServerError) + .bind() + + CreateAppDraftResponse(appDraftId) + } + override suspend fun getMyOrganization( context: CallContext, ): Either = either { diff --git a/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ServerErrorConverter.kt b/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ServerErrorConverter.kt index 36aff058..7e6637a1 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ServerErrorConverter.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/api/console2/ServerErrorConverter.kt @@ -5,6 +5,7 @@ package app.accrescent.server.domain.api.console2 import app.accrescent.server.core.causedBy +import app.accrescent.server.domain.IdGenerationError import app.accrescent.server.domain.ports.driven.datastore2.DataStoreError import app.accrescent.server.domain.ports.driving.api.console2.ServerError @@ -26,3 +27,13 @@ fun toServerError(error: DataStoreError): ServerError { return serverError.causedBy(error) } + +/** + * Converts an [IdGenerationError] into its corresponding [ServerError]. + * + * @param error the ID generation error to convert. + * @return the corresponding server error, caused by [error]. + */ +fun toServerError(error: IdGenerationError): ServerError { + return ServerError.InternalServerError().causedBy(error) +} diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStore.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStore.kt index 615717ef..3a7b683d 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStore.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStore.kt @@ -5,6 +5,7 @@ package app.accrescent.server.domain.ports.driven.datastore2 import app.accrescent.server.core.Error +import app.accrescent.server.core.NonNegativeLong import app.accrescent.server.domain.authn.ExternalUserId import app.accrescent.server.domain.crypto.Sha256Hash import arrow.core.Either @@ -109,6 +110,32 @@ interface DataStore { * Provides access to all data access methods which participate in a data store transaction. */ abstract class Transaction { + /** + * Counts the app drafts in an organization. + * + * @param organizationId the ID of the organization to count the app drafts of. + * @return the number of app drafts in the organization, or zero if the organization does + * not exist. + */ + abstract suspend fun countAppDraftsInOrg( + organizationId: String, + ): DataStoreResult + + /** + * Creates an app draft. + * + * @param appDraftId the ID of the app draft to create. + * @param organizationId the ID of the organization to create the app draft in. + * @param createTime the creation timestamp to set for the new app draft. + * @return [DataStoreError.ConsistencyViolation] if an app draft with the same ID already + * exists or the organization does not exist. + */ + abstract suspend fun createAppDraft( + appDraftId: String, + organizationId: String, + createTime: OffsetDateTime, + ): DataStoreResult + /** * Creates an organization with its owner. * diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/HasPermissionRequest.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/HasPermissionRequest.kt index 91fb8b3a..e547e8fe 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/HasPermissionRequest.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driven/datastore2/HasPermissionRequest.kt @@ -11,6 +11,15 @@ package app.accrescent.server.domain.ports.driven.datastore2 * @property subjectId the ID of the subject to check the permission of. */ sealed class HasPermissionRequest(val resourceId: String, val subjectId: String) { + /** + * The permission to create app drafts in an organization. + * + * @param organizationId the ID of the organization to create app drafts in. + * @param userId the ID of the user to check the permission of. + */ + data class CreateAppDraft(private val organizationId: String, private val userId: String) : + HasPermissionRequest(organizationId, userId) + /** * The permission to view an organization. * diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/ConsoleApi.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/ConsoleApi.kt index 7f631e4e..46618285 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/ConsoleApi.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/ConsoleApi.kt @@ -10,6 +10,17 @@ import arrow.core.Either * The Accrescent console API. */ interface ConsoleApi { + /** + * Creates an app draft. + * + * @param context the context for this call. + * @param request the request parameters. + */ + suspend fun createAppDraft( + context: CallContext, + request: CreateAppDraftRequest, + ): Either + /** * Gets the organization of the currently authenticated user. * diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftRequest.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftRequest.kt new file mode 100644 index 00000000..d41c49e1 --- /dev/null +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftRequest.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: © 2026 Logan Magee +// +// SPDX-License-Identifier: AGPL-3.0-only + +package app.accrescent.server.domain.ports.driving.api.console2 + +/** + * Request to create an app draft. + * + * @property organizationId the ID of the organization to create the app draft in. + */ +data class CreateAppDraftRequest(val organizationId: String) diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftResponse.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftResponse.kt new file mode 100644 index 00000000..40c73b8b --- /dev/null +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/CreateAppDraftResponse.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: © 2026 Logan Magee +// +// SPDX-License-Identifier: AGPL-3.0-only + +package app.accrescent.server.domain.ports.driving.api.console2 + +/** + * Response to creating an app draft. + * + * @property appDraftId the ID of the created app draft. + */ +data class CreateAppDraftResponse(val appDraftId: String) diff --git a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/Errors.kt b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/Errors.kt index 950b8be8..fefacaf7 100644 --- a/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/Errors.kt +++ b/server/src/main/kotlin/app/accrescent/server/domain/ports/driving/api/console2/Errors.kt @@ -6,6 +6,11 @@ package app.accrescent.server.domain.ports.driving.api.console2 import app.accrescent.server.core.Error +/** + * An error that can occur when creating an app draft. + */ +sealed interface CreateAppDraftError + /** * An error that can occur when getting the authenticated user's organization. */ @@ -24,7 +29,12 @@ sealed interface GetSelfError /** * A server-side error that can occur in any console API method. */ -sealed class ServerError : Error(), GetMyOrganizationError, GetOrganizationError, GetSelfError { +sealed class ServerError : + Error(), + CreateAppDraftError, + GetMyOrganizationError, + GetOrganizationError, + GetSelfError { /** * The server encountered an unexpected internal error. */ @@ -40,16 +50,28 @@ sealed class ServerError : Error(), GetMyOrganizationError, GetOrganizationError } } +/** + * The organization already has the maximum number of active app drafts allowed. + */ +class ActiveAppDraftLimitExceededError : Error(), CreateAppDraftError { + override val message = "active app draft limit exceeded" +} + /** * The caller does not have sufficient permission to perform the operation. */ -class InsufficientPermissionError : Error(), GetOrganizationError { +class InsufficientPermissionError : Error(), CreateAppDraftError, GetOrganizationError { override val message = "insufficient permission" } /** * The caller of a method which requires authentication is not authenticated. */ -class UnauthenticatedError : Error(), GetMyOrganizationError, GetOrganizationError, GetSelfError { +class UnauthenticatedError : + Error(), + CreateAppDraftError, + GetMyOrganizationError, + GetOrganizationError, + GetSelfError { override val message = "unauthenticated" } diff --git a/server/src/main/resources/db/migration/memory/V1__Add_initial_schema.sql b/server/src/main/resources/db/migration/memory/V1__Add_initial_schema.sql index dcbf455d..4458599a 100644 --- a/server/src/main/resources/db/migration/memory/V1__Add_initial_schema.sql +++ b/server/src/main/resources/db/migration/memory/V1__Add_initial_schema.sql @@ -25,3 +25,10 @@ CREATE TABLE sessions ( expire_time timestamp with time zone NOT NULL, CHECK (expire_time > create_time) ); + +-- App drafts +CREATE TABLE app_drafts ( + id varchar PRIMARY KEY, + organization_id varchar NOT NULL REFERENCES organizations(id), + create_time timestamp with time zone NOT NULL +); diff --git a/server/src/main/resources/db/migration/postgresql2/V1__Add_initial_schema.sql b/server/src/main/resources/db/migration/postgresql2/V1__Add_initial_schema.sql index f01c77f8..925fc306 100644 --- a/server/src/main/resources/db/migration/postgresql2/V1__Add_initial_schema.sql +++ b/server/src/main/resources/db/migration/postgresql2/V1__Add_initial_schema.sql @@ -36,3 +36,10 @@ CREATE TABLE sessions ( expire_time timestamp with time zone NOT NULL, CHECK (expire_time > create_time) ); + +-- App drafts +CREATE TABLE app_drafts ( + id stable_text PRIMARY KEY, + organization_id stable_text NOT NULL REFERENCES organizations(id), + create_time timestamp with time zone NOT NULL +); diff --git a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConvertersTest.kt b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConvertersTest.kt index 75e9aecd..2f5433f6 100644 --- a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConvertersTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/ErrorConvertersTest.kt @@ -4,6 +4,8 @@ package app.accrescent.server.adapters.driving.api.console.grpc +import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftError import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationError import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationError import app.accrescent.server.domain.ports.driving.api.console2.GetSelfError @@ -16,6 +18,16 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource class ErrorConvertersTest { + @ParameterizedTest + @MethodSource("toStatusCorrectlyConvertsCreateAppDraftErrorTestCases") + fun `toStatus correctly converts CreateAppDraftError`( + testCase: ToStatusCorrectlyConvertsCreateAppDraftErrorTestCase, + ) { + val status = toStatus(testCase.error) + + assertEquals(testCase.expectedCode, status.code) + } + @ParameterizedTest @MethodSource("toStatusCorrectlyConvertsGetMyOrganizationErrorTestCases") fun `toStatus correctly converts GetMyOrganizationError`( @@ -57,6 +69,11 @@ class ErrorConvertersTest { } companion object { + data class ToStatusCorrectlyConvertsCreateAppDraftErrorTestCase( + val error: CreateAppDraftError, + val expectedCode: Status.Code, + ) + data class ToStatusCorrectlyConvertsGetMyOrganizationErrorTestCase( val error: GetMyOrganizationError, val expectedCode: Status.Code, @@ -77,6 +94,25 @@ class ErrorConvertersTest { val expectedCode: Status.Code, ) + @JvmStatic + private fun toStatusCorrectlyConvertsCreateAppDraftErrorTestCases(): + List { + return listOf( + ToStatusCorrectlyConvertsCreateAppDraftErrorTestCase( + UnauthenticatedError(), + Status.Code.UNAUTHENTICATED, + ), + ToStatusCorrectlyConvertsCreateAppDraftErrorTestCase( + InsufficientPermissionError(), + Status.Code.PERMISSION_DENIED, + ), + ToStatusCorrectlyConvertsCreateAppDraftErrorTestCase( + ActiveAppDraftLimitExceededError(), + Status.Code.RESOURCE_EXHAUSTED, + ), + ) + } + @JvmStatic private fun toStatusCorrectlyConvertsGetMyOrganizationErrorTestCases(): List { diff --git a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConvertersTest.kt b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConvertersTest.kt index 4744a033..0a94d279 100644 --- a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConvertersTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/grpc/MessageConvertersTest.kt @@ -6,6 +6,8 @@ package app.accrescent.server.adapters.driving.api.console.grpc import app.accrescent.server.core.NonNegativeLong import app.accrescent.server.core.unwrap +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftResponse import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationRequest import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationResponse @@ -13,6 +15,8 @@ import app.accrescent.server.domain.ports.driving.api.console2.GetSelfResponse import app.accrescent.server.domain.ports.driving.api.console2.Organization import app.accrescent.server.domain.ports.driving.api.console2.User import app.accrescent.server.domain.ports.driving.api.console2.UserRole +import build.buf.gen.accrescent.console.v1.createAppDraftRequest +import build.buf.gen.accrescent.console.v1.createAppDraftResponse import build.buf.gen.accrescent.console.v1.getMyOrganizationResponse import build.buf.gen.accrescent.console.v1.getOrganizationRequest import build.buf.gen.accrescent.console.v1.getOrganizationResponse @@ -24,6 +28,14 @@ import org.junit.jupiter.api.Test import build.buf.gen.accrescent.console.v1.UserRole as UserRoleProto class MessageConvertersTest { + @Test + fun `toDomain correctly converts CreateAppDraftRequest`() { + val proto = createAppDraftRequest { organizationId = "org1" } + val domain = CreateAppDraftRequest("org1") + + assertEquals(domain, toDomain(proto)) + } + @Test fun `toDomain correctly converts GetOrganizationRequest`() { val proto = getOrganizationRequest { organizationId = "org1" } @@ -32,6 +44,14 @@ class MessageConvertersTest { assertEquals(domain, toDomain(proto)) } + @Test + fun `toProto correctly converts CreateAppDraftResponse`() { + val domain = CreateAppDraftResponse("draft1") + val proto = createAppDraftResponse { appDraftId = "draft1" } + + assertEquals(proto, toProto(domain)) + } + @Test fun `toProto correctly converts GetMyOrganizationResponse`() { val domain = GetMyOrganizationResponse("org1") diff --git a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServerTest.kt b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServerTest.kt index 3cb5d76e..ce0d0488 100644 --- a/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServerTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/adapters/driving/api/console/vertx/GrpcWebConsoleApiServerTest.kt @@ -5,7 +5,9 @@ package app.accrescent.server.adapters.driving.api.console.vertx import app.accrescent.server.adapters.driven.datastore2.vertx.MemoryDataStore +import app.accrescent.server.adapters.driven.randomsource.DeterministicRandomSource import app.accrescent.server.adapters.driven.timestampsource.FixedTimestampSource +import app.accrescent.server.domain.IdGenerator import app.accrescent.server.domain.api.console2.ConsoleApiImpl import app.accrescent.server.domain.authn.ExternalUserId import build.buf.gen.accrescent.console.v1.UserServiceGrpc @@ -59,6 +61,7 @@ class GrpcWebConsoleApiServerTest { private val client = vertx.createHttpClient() private val consoleApi = ConsoleApiImpl( dataStore = MemoryDataStore(vertx), + idGenerator = IdGenerator(DeterministicRandomSource()), timestampSource = FixedTimestampSource(), adminExternalUserId = ExternalUserId.Github(1), ) diff --git a/server/src/test/kotlin/app/accrescent/server/core/NonNegativeLongTest.kt b/server/src/test/kotlin/app/accrescent/server/core/NonNegativeLongTest.kt index bc7a5081..1768eccc 100644 --- a/server/src/test/kotlin/app/accrescent/server/core/NonNegativeLongTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/core/NonNegativeLongTest.kt @@ -28,4 +28,11 @@ class NonNegativeLongTest { assertTrue(result.isSome()) } + + @Test + fun `smaller value is less than larger value`() { + val result = NonNegativeLong.new(0).unwrap() < NonNegativeLong.new(1).unwrap() + + assertTrue(result) + } } diff --git a/server/src/test/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImplTest.kt b/server/src/test/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImplTest.kt index 74eaed80..2ec1ec6d 100644 --- a/server/src/test/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImplTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/domain/api/console2/ConsoleApiImplTest.kt @@ -18,8 +18,10 @@ import app.accrescent.server.domain.authn.ExternalUserId import app.accrescent.server.domain.config.SessionLifetime import app.accrescent.server.domain.ports.driven.datastore2.DataStore import app.accrescent.server.domain.ports.driven.timestampsource.TimestampSource +import app.accrescent.server.domain.ports.driving.api.console2.ActiveAppDraftLimitExceededError import app.accrescent.server.domain.ports.driving.api.console2.CallContext import app.accrescent.server.domain.ports.driving.api.console2.ConsoleApi +import app.accrescent.server.domain.ports.driving.api.console2.CreateAppDraftRequest import app.accrescent.server.domain.ports.driving.api.console2.GetMyOrganizationResponse import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationRequest import app.accrescent.server.domain.ports.driving.api.console2.GetOrganizationResponse @@ -38,6 +40,50 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf class ConsoleApiImplTest { + @Test + suspend fun `createAppDraft returns Unauthenticated for unauthenticated request`() { + withMigratedDataStore { dataStore -> + val consoleApi = makeConsoleApi(dataStore) + + val response = consoleApi.createAppDraft( + CallContext(None), + CreateAppDraftRequest("org_1uMy9o9BqyoxomLjIEbctU"), + ) + + assertInstanceOf(response.unwrapErr()) + } + } + + @Test + suspend fun `createAppDraft returns InsufficientPermission for unauthorized request`() { + withMigratedDataStore { dataStore -> + val context = signIn(dataStore) + dataStore.runInTransaction { tx -> + tx.createOrgWithOwner("org2", "user2", ExternalUserId.Github(2), UNIX_EPOCH).bind() + } + .unwrap2() + val consoleApi = makeConsoleApi(dataStore) + + val response = consoleApi.createAppDraft(context, CreateAppDraftRequest("org2")) + + assertInstanceOf(response.unwrapErr()) + } + } + + @Test + suspend fun `createAppDraft returns ActiveAppDraftLimitExceeded when attempting to create more than 3 app drafts in org`() { + withMigratedDataStore { dataStore -> + val context = signIn(dataStore) + val consoleApi = makeConsoleApi(dataStore) + val request = CreateAppDraftRequest("org_1uMy9o9BqyoxomLjIEbctU") + repeat(3) { consoleApi.createAppDraft(context, request).unwrap() } + + val response = consoleApi.createAppDraft(context, request) + + assertInstanceOf(response.unwrapErr()) + } + } + @Test suspend fun `getMyOrganization returns Unauthenticated when not given a session ID`() { withMigratedDataStore { dataStore -> @@ -133,6 +179,25 @@ class ConsoleApiImplTest { } } + @Test + suspend fun `getOrganization returns organization's accurate active app draft count`() { + withMigratedDataStore { dataStore -> + val context = signIn(dataStore) + val consoleApi = makeConsoleApi(dataStore) + val organizationId = "org_1uMy9o9BqyoxomLjIEbctU" + repeat(2) { + consoleApi.createAppDraft(context, CreateAppDraftRequest(organizationId)).unwrap() + } + + val response = consoleApi.getOrganization(context, GetOrganizationRequest(organizationId)) + + assertEquals( + NonNegativeLong.new(2).unwrap(), + response.unwrap().organization.activeAppDraftCount, + ) + } + } + @Test suspend fun `getSelf returns Unauthenticated for unauthenticated request`() { withMigratedDataStore { dataStore -> @@ -207,7 +272,12 @@ class ConsoleApiImplTest { timestampSource: TimestampSource = FixedTimestampSource(), admin: ExternalUserId = ExternalUserId.Github(1), ): ConsoleApi { - return ConsoleApiImpl(dataStore, timestampSource, admin) + return ConsoleApiImpl( + dataStore = dataStore, + idGenerator = IdGenerator(DeterministicRandomSource()), + timestampSource = timestampSource, + adminExternalUserId = admin, + ) } /** diff --git a/server/src/test/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStoreConformanceTest.kt b/server/src/test/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStoreConformanceTest.kt index 521b68a6..91e6ac07 100644 --- a/server/src/test/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStoreConformanceTest.kt +++ b/server/src/test/kotlin/app/accrescent/server/domain/ports/driven/datastore2/DataStoreConformanceTest.kt @@ -217,6 +217,70 @@ abstract class DataStoreConformanceTest { } } + @Test + suspend fun `countAppDraftsInOrg returns number of app drafts in organization`() { + withMigratedDataStore { dataStore -> + dataStore.runInTransaction { tx -> + tx.createOrgWithOwner("org1", "user1", ExternalUserId.Github(1), UNIX_EPOCH).bind() + tx.createOrgWithOwner("org2", "user2", ExternalUserId.Github(2), UNIX_EPOCH).bind() + tx.createAppDraft("draft1", "org1", UNIX_EPOCH).bind() + tx.createAppDraft("draft2", "org1", UNIX_EPOCH).bind() + tx.createAppDraft("draft3", "org2", UNIX_EPOCH).bind() + } + .unwrap2() + + val count = dataStore + .runInTransaction { tx -> tx.countAppDraftsInOrg("org1").bind() } + .unwrap2() + + assertEquals(2L, count.value) + } + } + + @Test + suspend fun `countAppDraftsInOrg returns zero for non-existent organization`() { + withMigratedDataStore { dataStore -> + val count = dataStore + .runInTransaction { tx -> tx.countAppDraftsInOrg("org1").bind() } + .unwrap2() + + assertEquals(NonNegativeLong.MIN_VALUE, count) + } + } + + @Test + suspend fun `createAppDraft returns ConsistencyViolation if app draft with same ID already exists`() { + withMigratedDataStore { dataStore -> + dataStore.runInTransaction { tx -> + tx.createOrgWithOwner("org1", "user1", ExternalUserId.Github(1), UNIX_EPOCH).bind() + tx.createOrgWithOwner("org2", "user2", ExternalUserId.Github(2), UNIX_EPOCH).bind() + tx.createAppDraft("draft1", "org1", UNIX_EPOCH).bind() + } + .unwrap2() + + val error = dataStore.runInTransaction { tx -> + tx.createAppDraft("draft1", "org2", UNIX_EPOCH).bind() + } + .unwrap() + .unwrapErr() + + assertInstanceOf(error) + } + } + + @Test + suspend fun `createAppDraft returns ConsistencyViolation if organization does not exist`() { + withMigratedDataStore { dataStore -> + val error = dataStore.runInTransaction { tx -> + tx.createAppDraft("draft1", "org1", UNIX_EPOCH).bind() + } + .unwrap() + .unwrapErr() + + assertInstanceOf(error) + } + } + @Test suspend fun `createOrgWithOwner returns ConsistencyViolation if org with same ID already exists`() { withMigratedDataStore { dataStore -> @@ -404,6 +468,7 @@ abstract class DataStoreConformanceTest { withMigratedDataStore { dataStore -> dataStore.runInTransaction { tx -> tx.createOrgWithOwner("org1", "user1", ExternalUserId.Github(1), UNIX_EPOCH).bind() + tx.createAppDraft("draft1", "org1", UNIX_EPOCH).bind() } .unwrap2() @@ -412,7 +477,7 @@ abstract class DataStoreConformanceTest { .unwrap2() assertEquals( - Some(OrgApiView("org1", NonNegativeLong.MIN_VALUE, NonNegativeLong.MIN_VALUE)), + Some(OrgApiView("org1", NonNegativeLong.MIN_VALUE, NonNegativeLong.new(1).unwrap())), organization, ) } @@ -572,6 +637,13 @@ abstract class DataStoreConformanceTest { @JvmStatic private fun hasPermissionReturnsTrueWithMinimalRelationshipsTestCases() : List = listOf( + HasPermissionReturnsTrueWithMinimalRelationships( + HasPermissionRequest.CreateAppDraft("org1", "user1"), + ) { tx -> + either { + tx.createOrgWithOwner("org1", "user1", ExternalUserId.Github(1), UNIX_EPOCH).bind() + } + }, HasPermissionReturnsTrueWithMinimalRelationships( HasPermissionRequest.ViewOrganization("org1", "user1"), ) { tx ->