From f25c9658ebe04e3b4a44bd97ed66dc78fe9742d8 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 31 Jan 2026 18:11:27 -0600 Subject: [PATCH] WIP: Starting on DB for real --- build.sbt | 4 +- .../scala/gs/smolban/auth/ClientSecret.scala | 29 +++ .../scala/gs/smolban/auth/Credential.scala | 29 +++ .../smolban/auth/CredentialEffectivity.scala | 39 ++++ .../scala/gs/smolban/auth/CredentialId.scala | 49 +++++ .../gs/smolban/auth/CredentialStatus.scala | 63 ++++++ .../gs/smolban/auth/CredentialType.scala | 66 ++++++ .../gs/smolban/auth/NewAiAgentAccount.scala | 16 ++ .../gs/smolban/auth/NewServiceAccount.scala | 16 ++ .../main/scala/gs/smolban/auth/Password.scala | 29 +++ .../src/main/resources/sql/{ => sqlite}/1.sql | 0 .../src/main/scala/gs/smolban/db/AuthDb.scala | 203 ++++++++++++++++++ .../main/scala/gs/smolban/db/DbError.scala | 72 +++++-- .../gs/smolban/db/doobie/DoobieTypes.scala | 54 ++--- .../scala/gs/smolban/model/group/Group.scala | 2 - 15 files changed, 612 insertions(+), 59 deletions(-) create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/ClientSecret.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/Credential.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/CredentialEffectivity.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/CredentialId.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/CredentialStatus.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/CredentialType.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/NewAiAgentAccount.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/NewServiceAccount.scala create mode 100644 modules/auth/src/main/scala/gs/smolban/auth/Password.scala rename modules/db/src/main/resources/sql/{ => sqlite}/1.sql (100%) create mode 100644 modules/db/src/main/scala/gs/smolban/db/AuthDb.scala diff --git a/build.sbt b/build.sbt index c1e6f3a..c9360a7 100644 --- a/build.sbt +++ b/build.sbt @@ -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") diff --git a/modules/auth/src/main/scala/gs/smolban/auth/ClientSecret.scala b/modules/auth/src/main/scala/gs/smolban/auth/ClientSecret.scala new file mode 100644 index 0000000..cc1128e --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/ClientSecret.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala b/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala new file mode 100644 index 0000000..240903d --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Credential.scala @@ -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 +) diff --git a/modules/auth/src/main/scala/gs/smolban/auth/CredentialEffectivity.scala b/modules/auth/src/main/scala/gs/smolban/auth/CredentialEffectivity.scala new file mode 100644 index 0000000..03b4518 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/CredentialEffectivity.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/CredentialId.scala b/modules/auth/src/main/scala/gs/smolban/auth/CredentialId.scala new file mode 100644 index 0000000..a84904f --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/CredentialId.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/CredentialStatus.scala b/modules/auth/src/main/scala/gs/smolban/auth/CredentialStatus.scala new file mode 100644 index 0000000..7c84540 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/CredentialStatus.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/CredentialType.scala b/modules/auth/src/main/scala/gs/smolban/auth/CredentialType.scala new file mode 100644 index 0000000..3989c66 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/CredentialType.scala @@ -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 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/NewAiAgentAccount.scala b/modules/auth/src/main/scala/gs/smolban/auth/NewAiAgentAccount.scala new file mode 100644 index 0000000..854ed35 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/NewAiAgentAccount.scala @@ -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 +) diff --git a/modules/auth/src/main/scala/gs/smolban/auth/NewServiceAccount.scala b/modules/auth/src/main/scala/gs/smolban/auth/NewServiceAccount.scala new file mode 100644 index 0000000..1cb7f61 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/NewServiceAccount.scala @@ -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 +) diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Password.scala b/modules/auth/src/main/scala/gs/smolban/auth/Password.scala new file mode 100644 index 0000000..82d8bf3 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Password.scala @@ -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 diff --git a/modules/db/src/main/resources/sql/1.sql b/modules/db/src/main/resources/sql/sqlite/1.sql similarity index 100% rename from modules/db/src/main/resources/sql/1.sql rename to modules/db/src/main/resources/sql/sqlite/1.sql diff --git a/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala b/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala new file mode 100644 index 0000000..223e850 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/AuthDb.scala @@ -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] diff --git a/modules/db/src/main/scala/gs/smolban/db/DbError.scala b/modules/db/src/main/scala/gs/smolban/db/DbError.scala index 739c4bb..279d67b 100644 --- a/modules/db/src/main/scala/gs/smolban/db/DbError.scala +++ b/modules/db/src/main/scala/gs/smolban/db/DbError.scala @@ -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 diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala index 5265c94..adc65ce 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala @@ -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 diff --git a/modules/model/src/main/scala/gs/smolban/model/group/Group.scala b/modules/model/src/main/scala/gs/smolban/model/group/Group.scala index a40d33a..1e6d1c5 100644 --- a/modules/model/src/main/scala/gs/smolban/model/group/Group.scala +++ b/modules/model/src/main/scala/gs/smolban/model/group/Group.scala @@ -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