WIP: Starting on DB for real
This commit is contained in:
parent
a7e2185204
commit
f25c9658eb
15 changed files with 612 additions and 59 deletions
|
|
@ -90,7 +90,7 @@ lazy val auth = project
|
|||
|
||||
lazy val db = project
|
||||
.in(file("modules/db"))
|
||||
.dependsOn(model)
|
||||
.dependsOn(model, auth)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettings)
|
||||
.settings(name := s"${gsProjectName.value}-db")
|
||||
|
|
@ -102,7 +102,7 @@ lazy val db = project
|
|||
|
||||
lazy val api = project
|
||||
.in(file("modules/api"))
|
||||
.dependsOn(model, db)
|
||||
.dependsOn(model, auth, db)
|
||||
.settings(sharedSettings)
|
||||
.settings(testSettings)
|
||||
.settings(name := s"${gsProjectName.value}-api")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
/** Client secrets _must_ be passed into Smolban as [[RsaEncryptedBytes]].
|
||||
* Smolban never assumes clear text inputs.
|
||||
*
|
||||
* This is an opaque type for encrypted bytes.
|
||||
*
|
||||
* This type is explicitly intended for use in authentication flows. It is not
|
||||
* intended for any other use (e.g. generating new secrets).
|
||||
*/
|
||||
opaque type ClientSecret = RsaEncryptedBytes
|
||||
|
||||
object ClientSecret:
|
||||
|
||||
given CanEqual[ClientSecret, ClientSecret] = CanEqual.derived
|
||||
|
||||
/** Instantiate a new [[ClientSecret]] from the given encrypted bytes.
|
||||
*
|
||||
* @param value
|
||||
* The encrypted bytes.
|
||||
* @return
|
||||
* The new [[ClientSecret]] instance.
|
||||
*/
|
||||
def apply(value: RsaEncryptedBytes): ClientSecret = value
|
||||
|
||||
extension (clientSecret: ClientSecret)
|
||||
def unwrap(): RsaEncryptedBytes = clientSecret
|
||||
|
||||
end ClientSecret
|
||||
29
modules/auth/src/main/scala/gs/smolban/auth/Credential.scala
Normal file
29
modules/auth/src/main/scala/gs/smolban/auth/Credential.scala
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import gs.smolban.model.account.AccountId
|
||||
import gs.smolban.model.metadata.CreatedAt
|
||||
|
||||
/** Describes some credential but does not contain the actual credential value.
|
||||
*
|
||||
* @param credentialId
|
||||
* The unique identifier of this credential.
|
||||
* @param accountId
|
||||
* The unique identifier of the account to which this credential is
|
||||
* associated.
|
||||
* @param credentialType
|
||||
* The type of credential.
|
||||
* @param status
|
||||
* The status of this credential.
|
||||
* @param effectivity
|
||||
* The effectivity of this credential, if defined.
|
||||
* @param createdAt
|
||||
* The instant at which this credential was created.
|
||||
*/
|
||||
case class Credential(
|
||||
credentialId: CredentialId,
|
||||
accountId: AccountId,
|
||||
credentialType: CredentialType,
|
||||
status: CredentialStatus,
|
||||
effectivity: Option[CredentialEffectivity],
|
||||
createdAt: CreatedAt
|
||||
)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.effect.Sync
|
||||
import cats.syntax.all.*
|
||||
import java.time.Clock
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Describes the effectivity for some credential. In Smolban, credentials _may_
|
||||
* be configured such that they are only valid for a certain period of time.
|
||||
*
|
||||
* @param startDate
|
||||
* The date where a credential becomes effective.
|
||||
* @param endDate
|
||||
* The date after which a credential is no longer effective.
|
||||
*/
|
||||
case class CredentialEffectivity(
|
||||
startDate: LocalDate,
|
||||
endDate: Option[LocalDate]
|
||||
)
|
||||
|
||||
object CredentialEffectivity:
|
||||
|
||||
given CanEqual[CredentialEffectivity, CredentialEffectivity] =
|
||||
CanEqual.derived
|
||||
|
||||
/** Produce a new credential effective starting today with no termination
|
||||
* date.
|
||||
*
|
||||
* @param clock
|
||||
* The clock to use for date selection.
|
||||
* @return
|
||||
* The new effectivity.
|
||||
*/
|
||||
def todayOnward[F[_]: Sync](clock: Clock): F[CredentialEffectivity] =
|
||||
Sync[F]
|
||||
.delay(LocalDate.now(clock))
|
||||
.map(start => CredentialEffectivity(start, None))
|
||||
|
||||
end CredentialEffectivity
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.Eq
|
||||
import cats.Show
|
||||
import gs.uuid.v0.UUID
|
||||
|
||||
/** Uniquely identifies a _single credential_ within Smolban.
|
||||
*/
|
||||
opaque type CredentialId = UUID
|
||||
|
||||
object CredentialId:
|
||||
|
||||
/** Instantiate a new [[CredentialId]] from the given UUID.
|
||||
*
|
||||
* @param value
|
||||
* The UUID.
|
||||
* @return
|
||||
* The new [[CredentialId]].
|
||||
*/
|
||||
def apply(value: UUID): CredentialId = value
|
||||
|
||||
/** Generate a new [[CredentialId]] using the UUIDv7 algorithm.
|
||||
*
|
||||
* @return
|
||||
* The new [[CredentialId]].
|
||||
*/
|
||||
def generate(): CredentialId = UUID.v7()
|
||||
|
||||
given CanEqual[CredentialId, CredentialId] = CanEqual.derived
|
||||
|
||||
given Show[CredentialId] = _.unwrap().withoutDashes()
|
||||
|
||||
given Eq[CredentialId] = (
|
||||
x,
|
||||
y
|
||||
) => x == y
|
||||
|
||||
extension (credentialId: CredentialId)
|
||||
/** @return
|
||||
* The underlying UUID value.
|
||||
*/
|
||||
def unwrap(): UUID = credentialId
|
||||
|
||||
/** @return
|
||||
* The underlying UUID value.
|
||||
*/
|
||||
def toUUID(): UUID = credentialId
|
||||
|
||||
end CredentialId
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.Eq
|
||||
import cats.Show
|
||||
|
||||
/** Enumeration that describes the status of an [[Credential]].
|
||||
*
|
||||
* Smolban credentials are either active or revoked. They cannot be deleted -
|
||||
* to preserve lineage of all operations.
|
||||
*/
|
||||
sealed abstract class CredentialStatus(val name: String):
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def equals(obj: Any): Boolean =
|
||||
obj match
|
||||
case other: CredentialStatus => name == other.name
|
||||
case _ => false
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def toString(): String = name
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def hashCode(): Int = name.hashCode()
|
||||
|
||||
object CredentialStatus:
|
||||
|
||||
given CanEqual[CredentialStatus, CredentialStatus] = CanEqual.derived
|
||||
|
||||
given Eq[CredentialStatus] = (
|
||||
x,
|
||||
y
|
||||
) => x == y
|
||||
|
||||
given Show[CredentialStatus] = _.name
|
||||
|
||||
/** The credential is active and can be used freely.
|
||||
*/
|
||||
case object Active extends CredentialStatus("active")
|
||||
|
||||
/** The credential is revoked and cannot be used.
|
||||
*/
|
||||
case object Revoked extends CredentialStatus("revoked")
|
||||
|
||||
/** List of all valid credential types.
|
||||
*/
|
||||
val All: List[CredentialStatus] =
|
||||
List(Active, Revoked)
|
||||
|
||||
/** Parse the given string as an [[CredentialStatus]].
|
||||
*
|
||||
* @param candidate
|
||||
* The string to parse.
|
||||
* @return
|
||||
* Some credential status value, or `None` if the given string is not a
|
||||
* valid credential status.
|
||||
*/
|
||||
def parse(candidate: String): Option[CredentialStatus] =
|
||||
All.find(_.name.equalsIgnoreCase(candidate))
|
||||
|
||||
end CredentialStatus
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import cats.Eq
|
||||
import cats.Show
|
||||
|
||||
/** Enumeration that defines all supported credential types in Smolban.
|
||||
*
|
||||
* Credentials are used to authenticate different types of accounts and grant
|
||||
* them _access tokens_.
|
||||
*
|
||||
* @param name
|
||||
* The name of the credential type.
|
||||
*/
|
||||
sealed abstract class CredentialType(val name: String):
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def equals(obj: Any): Boolean =
|
||||
obj match
|
||||
case other: CredentialType => name == other.name
|
||||
case _ => false
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def toString(): String = name
|
||||
|
||||
/** @inheritDocs
|
||||
*/
|
||||
override def hashCode(): Int = name.hashCode()
|
||||
|
||||
object CredentialType:
|
||||
|
||||
given CanEqual[CredentialType, CredentialType] = CanEqual.derived
|
||||
|
||||
given Eq[CredentialType] = (
|
||||
x,
|
||||
y
|
||||
) => x == y
|
||||
|
||||
given Show[CredentialType] = _.name
|
||||
|
||||
/** Password - typical for human users.
|
||||
*/
|
||||
case object Password extends CredentialType("password")
|
||||
|
||||
/** Client Secret - typical for service accounts and AI agents.
|
||||
*/
|
||||
case object ClientSecret extends CredentialType("client_secret")
|
||||
|
||||
/** List of all valid credential types.
|
||||
*/
|
||||
val All: List[CredentialType] =
|
||||
List(Password, ClientSecret)
|
||||
|
||||
/** Parse the given string as an [[CredentialType]].
|
||||
*
|
||||
* @param candidate
|
||||
* The string to parse.
|
||||
* @return
|
||||
* Some credential type value, or `None` if the given string is not a valid
|
||||
* credential type.
|
||||
*/
|
||||
def parse(candidate: String): Option[CredentialType] =
|
||||
All.find(_.name.equalsIgnoreCase(candidate))
|
||||
|
||||
end CredentialType
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import gs.smolban.model.account.AiAgent
|
||||
|
||||
/** Represents a [[AiAgent]] that was just created. This is the only time this
|
||||
* [[ClientSecret]] will be exposed.
|
||||
*
|
||||
* @param serviceAccount
|
||||
* The new [[AiAgent]].
|
||||
* @param clientSecret
|
||||
* The new Credential for the account.
|
||||
*/
|
||||
case class NewAiAgentAccount(
|
||||
serviceAccount: AiAgent,
|
||||
clientSecret: Base64
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
import gs.smolban.model.account.ServiceAccount
|
||||
|
||||
/** Represents a [[ServiceAccount]] that was just created. This is the only time
|
||||
* this [[ClientSecret]] will be exposed.
|
||||
*
|
||||
* @param serviceAccount
|
||||
* The new [[ServiceAccount]].
|
||||
* @param clientSecret
|
||||
* The new Credential for the account.
|
||||
*/
|
||||
case class NewServiceAccount(
|
||||
serviceAccount: ServiceAccount,
|
||||
clientSecret: Base64
|
||||
)
|
||||
29
modules/auth/src/main/scala/gs/smolban/auth/Password.scala
Normal file
29
modules/auth/src/main/scala/gs/smolban/auth/Password.scala
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package gs.smolban.auth
|
||||
|
||||
/** Passwords _must_ be passed into Smolban as [[RsaEncryptedBytes]]. Smolban
|
||||
* never assumes clear text inputs.
|
||||
*
|
||||
* This is an opaque type for encrypted bytes.
|
||||
*
|
||||
* This type is explicitly intended for use in authentication flows and
|
||||
* allowing users to define new passwords -- the only cases where a password
|
||||
* should be provided as input.
|
||||
*/
|
||||
opaque type Password = RsaEncryptedBytes
|
||||
|
||||
object Password:
|
||||
|
||||
given CanEqual[Password, Password] = CanEqual.derived
|
||||
|
||||
/** Instantiate a new [[Password]] from the given encrypted bytes.
|
||||
*
|
||||
* @param value
|
||||
* The encrypted bytes.
|
||||
* @return
|
||||
* The new [[Password]] instance.
|
||||
*/
|
||||
def apply(value: RsaEncryptedBytes): Password = value
|
||||
|
||||
extension (password: Password) def unwrap(): RsaEncryptedBytes = password
|
||||
|
||||
end Password
|
||||
203
modules/db/src/main/scala/gs/smolban/db/AuthDb.scala
Normal file
203
modules/db/src/main/scala/gs/smolban/db/AuthDb.scala
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package gs.smolban.db
|
||||
|
||||
import cats.data.EitherT
|
||||
import gs.smolban.auth.Base64
|
||||
import gs.smolban.auth.Credential
|
||||
import gs.smolban.auth.CredentialId
|
||||
import gs.smolban.auth.NewAiAgentAccount
|
||||
import gs.smolban.auth.NewServiceAccount
|
||||
import gs.smolban.auth.Password
|
||||
import gs.smolban.model.account.Account
|
||||
import gs.smolban.model.account.AccountId
|
||||
import gs.smolban.model.account.AccountName
|
||||
import gs.smolban.model.account.AiAgent
|
||||
import gs.smolban.model.account.PermissionSet
|
||||
import gs.smolban.model.account.ServiceAccount
|
||||
import gs.smolban.model.account.User
|
||||
|
||||
/** Database interface for [[Account]] and related management activities.
|
||||
*/
|
||||
trait AuthDb[F[_]]:
|
||||
|
||||
/** Get the user account with the given name.
|
||||
*
|
||||
* @param name
|
||||
* The [[AccountName]] for the user.
|
||||
* @return
|
||||
* The [[User]], or nothing if the user does not exist.
|
||||
*/
|
||||
def getUser(
|
||||
name: AccountName
|
||||
): F[Option[User]]
|
||||
|
||||
/** Create a new [[User]].
|
||||
*
|
||||
* @param name
|
||||
* The user's unique [[AccountName]].
|
||||
* @param initialPassword
|
||||
* The initial [[Password]] for the [[User]].
|
||||
* @param initialPermissions
|
||||
* The initial permissions for the account.
|
||||
* @return
|
||||
* The new [[User]], or an error if creation fails.
|
||||
*/
|
||||
def createUser(
|
||||
name: AccountName,
|
||||
initialPassword: Password,
|
||||
initialPermissions: PermissionSet
|
||||
): EitherT[F, DbError, User]
|
||||
|
||||
/** Update the password for an existing [[User]].
|
||||
*
|
||||
* @param id
|
||||
* The user's unique identifier.
|
||||
* @param newPassword
|
||||
* The new [[Password]] for the [[User]].
|
||||
* @return
|
||||
* The updated [[User]], or an error if the update fails.
|
||||
*/
|
||||
def setUserPassword(
|
||||
id: AccountId,
|
||||
newPassword: Password
|
||||
): EitherT[F, DbError, User]
|
||||
|
||||
/** @return
|
||||
* List of all active [[User]].
|
||||
*/
|
||||
def listActiveUsers(): F[List[User]]
|
||||
|
||||
/** Get the [[ServiceAccount]] with the given name.
|
||||
*
|
||||
* @param name
|
||||
* The [[AccountName]] for the service.
|
||||
* @return
|
||||
* The [[ServiceAccount]], or nothing if the service account does not
|
||||
* exist.
|
||||
*/
|
||||
def getServiceAccount(
|
||||
name: AccountName
|
||||
): F[Option[ServiceAccount]]
|
||||
|
||||
/** Create a new [[ServiceAccount]].
|
||||
*
|
||||
* @param name
|
||||
* The service's unique [[AccountName]].
|
||||
* @param owner
|
||||
* The account which owns this [[ServiceAccount]].
|
||||
* @param initialPermissions
|
||||
* The initial permissions for the account.
|
||||
* @return
|
||||
* The new [[ServiceAccount]], or an error if creation fails.
|
||||
*/
|
||||
def createServiceAccount(
|
||||
name: AccountName,
|
||||
owner: AccountId,
|
||||
initialPermissions: PermissionSet
|
||||
): EitherT[F, DbError, NewServiceAccount]
|
||||
|
||||
/** @return
|
||||
* List of all active [[ServiceAccount]].
|
||||
*/
|
||||
def listActiveServiceAccounts(): F[List[ServiceAccount]]
|
||||
|
||||
/** Get the [[AiAgent]] with the given name.
|
||||
*
|
||||
* @param name
|
||||
* The [[AccountName]] for the agent.
|
||||
* @return
|
||||
* The [[AiAgent]], or nothing if the agent account does not exist.
|
||||
*/
|
||||
def getAgentAccount(
|
||||
name: AccountName
|
||||
): F[Option[AiAgent]]
|
||||
|
||||
/** Create a new [[AiAgent]].
|
||||
*
|
||||
* @param name
|
||||
* The agent's unique [[AccountName]].
|
||||
* @param owner
|
||||
* The account which owns this [[AiAgent]].
|
||||
* @param initialPermissions
|
||||
* The initial permissions for the account.
|
||||
* @return
|
||||
* The new [[AiAgent]], or an error if creation fails.
|
||||
*/
|
||||
def createAgentAccount(
|
||||
name: AccountName,
|
||||
owner: AccountId,
|
||||
initialPermissions: PermissionSet
|
||||
): EitherT[F, DbError, NewAiAgentAccount]
|
||||
|
||||
/** @return
|
||||
* List of all active [[AiAgent]].
|
||||
*/
|
||||
def listActiveAgentAccounts(): F[List[AiAgent]]
|
||||
|
||||
/** Rotate the specified secret and return the new secret. This is the only
|
||||
* time the new value will ever be exposed.
|
||||
*
|
||||
* This function will fail if the target credential is not owned by the
|
||||
* target account, or if the credential is not a client secret.
|
||||
*
|
||||
* @param accountId
|
||||
* The unique identifier of the [[Account]] which owns this secret.
|
||||
* @param credentialId
|
||||
* The unique identifier of the secret to rotate.
|
||||
* @param overlapHours
|
||||
* The number of hours to allow the old credential to continue working. 0
|
||||
* indicates it will expire immediately.
|
||||
* @return
|
||||
* The new secret value.
|
||||
*/
|
||||
def rotateClientSecret(
|
||||
accountId: AccountId,
|
||||
credentialId: CredentialId,
|
||||
overlapHours: Int
|
||||
): EitherT[F, DbError, Base64]
|
||||
|
||||
/** Replace the permissions assigned to some [[Account]].
|
||||
*
|
||||
* @param id
|
||||
* The [[AccountId]] that uniquely identifies the [[Account]].
|
||||
* @param newPermissions
|
||||
* The new permissions for the account.
|
||||
* @return
|
||||
* The updated [[Account]].
|
||||
*/
|
||||
def setAccountPermissions(
|
||||
id: AccountId,
|
||||
newPermissions: PermissionSet
|
||||
): EitherT[F, DbError, Account]
|
||||
|
||||
/** List all credentials that exist for the given [[AccountId]].
|
||||
*
|
||||
* @param id
|
||||
* The [[AccountId]] that uniquely identifies some [[Account]].
|
||||
* @return
|
||||
* The list of credentials associated with the [[Account]].
|
||||
*/
|
||||
def listAccountCredentials(
|
||||
id: AccountId
|
||||
): F[List[Credential]]
|
||||
|
||||
/** Revoke a credential, making it unusable.
|
||||
*
|
||||
* @param id
|
||||
* The unique identifier of the credential to revoke.
|
||||
* @return
|
||||
* Nothing.
|
||||
*/
|
||||
def revokeCredential(
|
||||
id: CredentialId
|
||||
): EitherT[F, DbError, Unit]
|
||||
|
||||
/** Expire a credential, making it unusable.
|
||||
*
|
||||
* @param id
|
||||
* The unique identifier of the credential to expire.
|
||||
* @return
|
||||
* Nothing.
|
||||
*/
|
||||
def expireCredential(
|
||||
id: CredentialId
|
||||
): EitherT[F, DbError, Unit]
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
package gs.smolban.db
|
||||
|
||||
import gs.slug.v0.Slug
|
||||
import gs.smolban.model.Group
|
||||
import gs.smolban.auth.CredentialId
|
||||
import gs.smolban.model.SmolbanError
|
||||
import gs.smolban.model.Tag
|
||||
import gs.smolban.model.Ticket
|
||||
import gs.smolban.model.account.AccountId
|
||||
import gs.smolban.model.account.AccountName
|
||||
import gs.smolban.model.account.AccountType
|
||||
|
||||
/** Parent type of all database errors in Smolban.
|
||||
*/
|
||||
|
|
@ -12,23 +12,63 @@ sealed trait DbError extends SmolbanError
|
|||
|
||||
object DbError:
|
||||
|
||||
/** Returned if an attempt is made to insert a [[Ticket]], where the Ticket ID
|
||||
* already exists for the specified [[gs.smolban.model.Group]].
|
||||
/** Produced when creating any new account fails because an account with the
|
||||
* same name already exists.
|
||||
*
|
||||
* @param ref
|
||||
* The Ticket Reference that was in conflict.
|
||||
* Note: this error exists for internal logging, but the user-facing error
|
||||
* should be properly sanitized.
|
||||
*
|
||||
* @param candidateAccountName
|
||||
* The account name.
|
||||
* @param accountType
|
||||
* The type of account.
|
||||
*/
|
||||
case class TicketAlreadyExists(ref: Ticket.Reference) extends DbError
|
||||
|
||||
case class GroupAlreadyExists(
|
||||
id: Group.Id,
|
||||
slug: Slug
|
||||
case class AccountAlreadyExists(
|
||||
candidateAccountName: AccountName,
|
||||
accountType: AccountType
|
||||
) extends DbError
|
||||
|
||||
case class TagAlreadyExists(
|
||||
value: Tag.Value
|
||||
/** Produced when performing account operations on an account that does not
|
||||
* exist within the Smolban database.
|
||||
*
|
||||
* @param accountId
|
||||
* The unique identifier for the account.
|
||||
*/
|
||||
case class AccountNotFound(
|
||||
accountId: AccountId
|
||||
) extends DbError
|
||||
|
||||
case class SilentInsertFailure(context: String) extends DbError
|
||||
/** Produced when attempting to update the credential (e.g. a password) for
|
||||
* some account. The database transaction is the only place where the new
|
||||
* candidate value lives unencrypted, and therefore is the only place where
|
||||
* credential requirements can be checked.
|
||||
*
|
||||
* This error is produced if some candidate credential does not meet Smolban
|
||||
* requirements.
|
||||
*
|
||||
* @param accountId
|
||||
* The unique identifier for the account.
|
||||
*/
|
||||
case class CredentialDoesNotMeetRequirements(accountId: AccountId)
|
||||
extends DbError
|
||||
|
||||
case class CredentialAlreadyRevoked(credentialId: CredentialId)
|
||||
extends DbError
|
||||
|
||||
case class CredentialAlreadyExpired(credentialId: CredentialId)
|
||||
extends DbError
|
||||
|
||||
case class CredentialNotFound(credentialId: CredentialId) extends DbError
|
||||
|
||||
case class NotAUser(accountId: AccountId) extends DbError
|
||||
|
||||
case class NotAServiceAccount(accountId: AccountId) extends DbError
|
||||
|
||||
case class NotAnAiAgent(accountId: AccountId) extends DbError
|
||||
|
||||
case class InvalidAccountOwner(
|
||||
accountId: AccountId,
|
||||
credentialId: CredentialId
|
||||
) extends DbError
|
||||
|
||||
end DbError
|
||||
|
|
|
|||
|
|
@ -2,37 +2,32 @@ package gs.smolban.db.doobie
|
|||
|
||||
import DoobieTypes.ErrorMessages
|
||||
import doobie.*
|
||||
import gs.slug.v0.Slug
|
||||
import gs.smolban.model.CreatedAt
|
||||
import gs.smolban.model.Group
|
||||
import gs.smolban.model.Tag
|
||||
import gs.smolban.model.Ticket
|
||||
import gs.smolban.model.metadata.CreatedAt
|
||||
import gs.smolban.model.metadata.Tag
|
||||
import gs.smolban.model.metadata.TagValue
|
||||
import gs.smolban.model.ticket.CommentId
|
||||
import gs.smolban.model.ticket.Ticket
|
||||
import gs.smolban.model.ticket.TicketStatus
|
||||
import gs.uuid.v0.UUID
|
||||
import gs.uuid.v0.UUIDFormat
|
||||
import java.time.Instant
|
||||
|
||||
trait DoobieTypes:
|
||||
|
||||
implicit val ticketNumberGet: Get[Ticket.Number] =
|
||||
Get[Long].tmap(Ticket.Number(_))
|
||||
|
||||
implicit val ticketNumberPut: Put[Ticket.Number] =
|
||||
Put[Long].tcontramap(_.toLong())
|
||||
|
||||
implicit val ticketStatusGet: Get[Ticket.Status] =
|
||||
implicit val ticketStatusGet: Get[TicketStatus] =
|
||||
Get[String].temap(dbValue =>
|
||||
Ticket.Status.parse(dbValue) match
|
||||
TicketStatus.parse(dbValue) match
|
||||
case None => Left(ErrorMessages.invalidTicketStatus(dbValue))
|
||||
case Some(status) => Right(status)
|
||||
)
|
||||
|
||||
implicit val ticketStatusPut: Put[Ticket.Status] =
|
||||
implicit val ticketStatusPut: Put[TicketStatus] =
|
||||
Put[String].tcontramap(_.name)
|
||||
|
||||
implicit val groupIdGet: Get[Group.Id] =
|
||||
Get[Array[Byte]].tmap(bytes => Group.Id(UUID(UUIDFormat.fromBytes(bytes))))
|
||||
implicit val commentIdGet: Get[CommentId] =
|
||||
Get[Array[Byte]].tmap(bytes => CommentId(UUID(UUIDFormat.fromBytes(bytes))))
|
||||
|
||||
implicit val groupIdPut: Put[Group.Id] =
|
||||
implicit val commentIdPut: Put[CommentId] =
|
||||
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
|
||||
|
||||
implicit val createdAtGet: Get[CreatedAt] =
|
||||
|
|
@ -47,30 +42,14 @@ trait DoobieTypes:
|
|||
implicit val instantPut: Put[Instant] =
|
||||
Put[Long].tcontramap(_.toEpochMilli())
|
||||
|
||||
implicit val slugGet: Get[Slug] =
|
||||
Get[String].temap { slug =>
|
||||
Slug.validate(slug) match
|
||||
case None => Left(ErrorMessages.invalidSlug(slug))
|
||||
case Some(s) => Right(s)
|
||||
}
|
||||
|
||||
implicit val slugPut: Put[Slug] =
|
||||
Put[String].tcontramap(_.str())
|
||||
|
||||
implicit val tagIdGet: Get[Tag.Id] =
|
||||
Get[Long].tmap(Tag.Id(_))
|
||||
|
||||
implicit val tagIdPut: Put[Tag.Id] =
|
||||
Put[Long].tcontramap(_.toLong())
|
||||
|
||||
implicit val tagValueGet: Get[Tag.Value] =
|
||||
implicit val tagValueGet: Get[TagValue] =
|
||||
Get[String].temap { value =>
|
||||
Tag.Value.validate(value) match
|
||||
TagValue.validate(value) match
|
||||
case None => Left(ErrorMessages.invalidTagValue(value))
|
||||
case Some(s) => Right(s)
|
||||
}
|
||||
|
||||
implicit val tagValuePut: Put[Tag.Value] =
|
||||
implicit val tagValuePut: Put[TagValue] =
|
||||
Put[String].tcontramap(_.toString())
|
||||
|
||||
object DoobieTypes extends DoobieTypes:
|
||||
|
|
@ -83,9 +62,6 @@ object DoobieTypes extends DoobieTypes:
|
|||
def invalidTicketStatus(candidate: String): String =
|
||||
s"'$candidate' is not a valid ticket status."
|
||||
|
||||
def invalidSlug(candidate: String): String =
|
||||
s"'$candidate' is not a valid Group slug."
|
||||
|
||||
end ErrorMessages
|
||||
|
||||
end DoobieTypes
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import gs.smolban.model.metadata.Title
|
|||
/** Groups are the basic unit of organization in Smolban. Each [[Ticket]]
|
||||
* belongs to a single `Group`.
|
||||
*
|
||||
* TODO: Ownership. Members.
|
||||
*
|
||||
* @param id
|
||||
* The unique identifier for the group.
|
||||
* @param name
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue