diff --git a/build.sbt b/build.sbt index 8aa1307..c1e6f3a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -val scala3: String = "3.4.2" +val scala3: String = "3.8.1" ThisBuild / scalaVersion := scala3 ThisBuild / gsProjectName := "smolban" @@ -18,33 +18,36 @@ lazy val sharedSettings = Seq( val Deps = new { val Cats = new { - val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.10.0" - val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.5.4" + val Core: ModuleID = "org.typelevel" %% "cats-core" % "2.13.0" + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3" } val Fs2 = new { - val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.10.2" + val Core: ModuleID = "co.fs2" %% "fs2-core" % "3.12.2" } val Doobie = new { - val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-M5" + val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-RC11" } val Http4s = new { - val Core: ModuleID = "org.http4s" %% "http4s-core" % "1.0.0-M41" - val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % "1.0.0-M41" + val Core: ModuleID = "org.http4s" %% "http4s-core" % "1.0.0-M45" + val Dsl: ModuleID = "org.http4s" %% "http4s-dsl" % "1.0.0-M45" val EmberServer: ModuleID = - "org.http4s" %% "http4s-ember-server" % "1.0.0-M41" + "org.http4s" %% "http4s-ember-server" % "1.0.0-M45" + } + + val BouncyCastle = new { + val Provider: ModuleID = "org.bouncycastle" % "bcprov-jdk18on" % "1.83" } val Gs = new { - val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.3.0" - val Slug: ModuleID = "gs" %% "gs-slug-v0" % "0.1.3" - val Config: ModuleID = "gs" %% "gs-config-v0" % "0.1.1" - val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.2.0" + val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.4.2" + val Config: ModuleID = "gs" %% "gs-config-v0" % "0.2.0" + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.4.1" } - val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.0-RC1" + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.1" } lazy val testSettings = Seq( @@ -68,11 +71,23 @@ lazy val model = project .settings( libraryDependencies ++= Seq( Deps.Gs.Uuid, - Deps.Gs.Slug, Deps.Cats.Core ) ) +lazy val auth = project + .in(file("modules/auth")) + .dependsOn(model) + .settings(sharedSettings) + .settings(testSettings) + .settings(name := s"${gsProjectName.value}-auth") + .settings( + libraryDependencies ++= Seq( + Deps.BouncyCastle.Provider, + Deps.Cats.Effect + ) + ) + lazy val db = project .in(file("modules/db")) .dependsOn(model) diff --git a/modules/model/src/main/scala/gs/smolban/model/Group.scala b/modules/model/src/main/scala/gs/smolban/model/Group.scala deleted file mode 100644 index 31994b6..0000000 --- a/modules/model/src/main/scala/gs/smolban/model/Group.scala +++ /dev/null @@ -1,56 +0,0 @@ -package gs.smolban.model - -import cats.Show -import gs.slug.v0.Slug -import gs.uuid.v0.UUID - -/** Groups are the basic unit of organization in Smolban. Each [[Ticket]] - * belongs to a single `Group`. - * - * @param id - * The unique identifier for the group. - * @param slug - * The unique slug for the group. - * @param createdAt - * The instant at which this group was created. - */ -case class Group( - id: Group.Id, - slug: Slug, - createdAt: CreatedAt -) - -object Group: - - given CanEqual[Group, Group] = CanEqual.derived - - /** Unique identifier for a [[Group]]. This is an opaque type for a UUID. - */ - opaque type Id = UUID - - object Id: - - /** Instantiate a new [[Group.Id]]. - * - * @param id - * The underlying UUID. - * @return - * The new [[Group.Id]] instance. - */ - def apply(id: UUID): Id = id - - given CanEqual[Id, Id] = CanEqual.derived - - given Show[Id] = _.toUUID().withoutDashes() - - extension (id: Id) - /** Unwrap this Group ID. - * - * @return - * The underlying UUID value. - */ - def toUUID(): UUID = id - - end Id - -end Group diff --git a/modules/model/src/main/scala/gs/smolban/model/Tag.scala b/modules/model/src/main/scala/gs/smolban/model/Tag.scala deleted file mode 100644 index 0c1566c..0000000 --- a/modules/model/src/main/scala/gs/smolban/model/Tag.scala +++ /dev/null @@ -1,63 +0,0 @@ -package gs.smolban.model - -import cats.Show - -/** Represents a smolban "tag", or arbitrary string descriptor. - * - * @param id - * The unique ID for this tag. - * @param value - * The unique value of this tag. - * @param createdAt - * The instant this tag was created. - */ -case class Tag( - id: Tag.Id, - value: Tag.Value, - createdAt: CreatedAt -) - -object Tag: - - given CanEqual[Tag, Tag] = CanEqual.derived - - /** Opaque type which represents a [[Tag]] unique identifier. This is a long - * integer. - */ - opaque type Id = Long - - object Id: - def apply(id: Long): Id = id - - given CanEqual[Id, Id] = CanEqual.derived - - given Show[Id] = t => t.toString() - - extension (id: Id) def toLong(): Long = id - - end Id - - /** Opaque type which represents a [[Tag]] value. These values are non-empty, - * arbitrary strings. - */ - opaque type Value = String - - object Value: - - /** Validate the candidate string - Tag Values must be non-empty. - * - * @param candidate - * The candidate string. - * @return - * The valid value, or `None` if an empty string was given. - */ - def validate(candidate: String): Option[Value] = - if candidate.isEmpty() then None else Some(candidate) - - given CanEqual[Value, Value] = CanEqual.derived - - given Show[Value] = t => t - - end Value - -end Tag diff --git a/modules/model/src/main/scala/gs/smolban/model/Ticket.scala b/modules/model/src/main/scala/gs/smolban/model/Ticket.scala deleted file mode 100644 index 0d3d88a..0000000 --- a/modules/model/src/main/scala/gs/smolban/model/Ticket.scala +++ /dev/null @@ -1,125 +0,0 @@ -package gs.smolban.model - -import cats.Show -import java.time.Instant - -/** Tickets represent some tracked work. - * - * @param number - * Unique number _within_ the [[Group]] Typically ascending integers. - * @param group - * Unique identifier of the [[Group]] that owns this ticket. - * @param createdAt - * The instant at which this ticket was created. - * @param title - * Arbitrary string title of the ticket. - * @param description - * Markdown contents of the ticket. - * @param tags - * List of [[Tag]] applied to this ticket. - * @param status - * Current [[Ticket.Status]] of this ticket. - * @param statusHistory - * Linear history of this ticket in terms of status changes. - */ -case class Ticket( - number: Ticket.Number, - group: Group.Id, - createdAt: CreatedAt, - title: String, - description: String, - tags: List[Tag], - status: Ticket.Status, - statusHistory: List[(Ticket.Status, Instant)] -): - lazy val reference: Ticket.Reference = Ticket.Reference(number, group) - -object Ticket: - - /** Composite reference that uniquely addresses any [[Ticket]]. - * - * @param number - * The ticket's unique identifier within the [[Group]]. - * @param group - * The unique identifier of the [[Group]]. - */ - case class Reference( - number: Ticket.Number, - group: Group.Id - ) - - /** Unique identifier - relative to some [[Group]] - for a [[Ticket]]. This is - * an opaque type for a Long. In general, [[Ticket]] identifiers are - * sequences within a group. - */ - opaque type Number = Long - - object Number: - - /** Instantiate a new [[Ticket.Number]]. - * - * @param number - * The underlying Long. - * @return - * The new [[Ticket.Number]] instance. - */ - def apply(number: Long): Number = number - - given CanEqual[Number, Number] = CanEqual.derived - - given Show[Number] = _.toLong().toString() - - extension (number: Number) - /** Unwrap this Ticket ID. - * - * @return - * The underlying Long value. - */ - def toLong(): Long = number - - end Number - - /** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban - * does not yet support custom status/workflow. - */ - sealed abstract class Status(val name: String) - - object Status: - - given CanEqual[Status, Status] = CanEqual.derived - - given Show[Status] = _.name - - /** This ticket is new, and ready to be started. New tickets may be put into - * progress or canceled. - */ - case object Ready extends Status("ready") - - /** This ticket is being worked on actively. In progress tickets can be - * paused, completed, or canceled. - */ - case object InProgress extends Status("in_progress") - - /** This ticket was being worked on, but was temporarily stopped. Paused - * tickets may be put into progress or canceled. - */ - case object Paused extends Status("paused") - - /** This ticket was driven to completion. The work is done. Once in this - * state, the status may no longer change. - */ - case object Complete extends Status("complete") - - /** This ticket was canceled for some reason. Once in this state, the status - * may no longer change. - */ - case object Canceled extends Status("canceled") - - val All: List[Status] = List(Ready, InProgress, Paused, Complete, Canceled) - - def parse(candidate: String): Option[Status] = - All.find(_.name.equalsIgnoreCase(candidate)) - - end Status - -end Ticket diff --git a/modules/model/src/main/scala/gs/smolban/model/account/Account.scala b/modules/model/src/main/scala/gs/smolban/model/account/Account.scala new file mode 100644 index 0000000..fa86ccf --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/Account.scala @@ -0,0 +1,128 @@ +package gs.smolban.model.account + +import gs.smolban.model.metadata.CreatedAt +import gs.smolban.model.metadata.Description + +/** Represents some Account - any entity that can be represented in Smolban and + * take some form of action in Smolban. + * + * @param accountType + * The [[AccountType]]. + */ +sealed abstract class Account(val accountType: AccountType): + /** @return + * The unique identifier for this [[Account]]. + */ + def id: AccountId + + /** @return + * The unique name for this [[Account]]. + */ + def name: AccountName + + /** @return + * The current status of this [[Account]]. + */ + def status: AccountStatus + + /** @return + * The complete set of [[Permission]] assigned to this account. + */ + def permissions: PermissionSet + + /** @return + * The instant this [[Account]] was created. + */ + def createdAt: CreatedAt + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Account => id == other.id + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = id.unwrap().hashCode() + + /** @inheritDocs + */ + override def toString(): String = + s"${accountType.name}:${id.unwrap().withoutDashes()}" + +/** Account for regular human users. + * + * @param id + * The unique identifier for this [[Account]]. + * @param name + * The unique name for this [[Account]]. + * @param status + * The current status of this [[Account]]. + * @param permissions + * The complete set of [[Permission]] assigned to this account. + * @param createdAt + * The instant this [[Account]] was created. + */ +final class User( + val id: AccountId, + val name: AccountName, + val status: AccountStatus, + val permissions: PermissionSet, + val createdAt: CreatedAt +) extends Account(AccountType.User) + +/** Account for arbitrary services and integrations. + * + * @param id + * The unique identifier for this [[Account]]. + * @param name + * The unique name for this [[Account]]. + * @param status + * The current status of this [[Account]]. + * @param permissions + * The complete set of [[Permission]] assigned to this account. + * @param description + * The purpose of this service account. + * @param createdAt + * The instant this [[Account]] was created. + * @param owner + * The account which owns this service account. This will always be a + * [[User]]. + */ +final class ServiceAccount( + val id: AccountId, + val name: AccountName, + val status: AccountStatus, + val permissions: PermissionSet, + val description: Description, + val createdAt: CreatedAt, + val owner: AccountId +) extends Account(AccountType.Service) + +/** Account to specifically represent AI Agents. + * + * @param id + * The unique identifier for this [[Account]]. + * @param name + * The unique name for this [[Account]]. + * @param status + * The current status of this [[Account]]. + * @param permissions + * The complete set of [[Permission]] assigned to this account. + * @param description + * The purpose of this AI Agent integration. + * @param createdAt + * The instant this [[Account]] was created. + * @param owner + * The account which owns this agent account. This will always be a [[User]]. + */ +final class AiAgent( + val id: AccountId, + val name: AccountName, + val status: AccountStatus, + val permissions: PermissionSet, + val description: Description, + val createdAt: CreatedAt, + val owner: AccountId +) extends Account(AccountType.Agent) diff --git a/modules/model/src/main/scala/gs/smolban/model/account/AccountId.scala b/modules/model/src/main/scala/gs/smolban/model/account/AccountId.scala new file mode 100644 index 0000000..87b098b --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/AccountId.scala @@ -0,0 +1,49 @@ +package gs.smolban.model.account + +import cats.Eq +import cats.Show +import gs.uuid.v0.UUID + +/** Uniquely identifies a _single account_ within Smolban. + */ +opaque type AccountId = UUID + +object AccountId: + + /** Instantiate a new [[AccountId]] from the given UUID. + * + * @param value + * The UUID. + * @return + * The new [[AccountId]]. + */ + def apply(value: UUID): AccountId = value + + /** Generate a new [[AccountId]] using the UUIDv7 algorithm. + * + * @return + * The new [[AccountId]]. + */ + def generate(): AccountId = UUID.v7() + + given CanEqual[AccountId, AccountId] = CanEqual.derived + + given Show[AccountId] = _.unwrap().withoutDashes() + + given Eq[AccountId] = ( + x, + y + ) => x == y + + extension (accountId: AccountId) + /** @return + * The underlying UUID value. + */ + def unwrap(): UUID = accountId + + /** @return + * The underlying UUID value. + */ + def toUUID(): UUID = accountId + +end AccountId diff --git a/modules/model/src/main/scala/gs/smolban/model/account/AccountName.scala b/modules/model/src/main/scala/gs/smolban/model/account/AccountName.scala new file mode 100644 index 0000000..4f1e99c --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/AccountName.scala @@ -0,0 +1,44 @@ +package gs.smolban.model.account + +import cats.Eq +import cats.Show +import scala.util.matching.Regex + +/** Opaque type which represents the name of some account. These values are + * non-empty, arbitrary strings, with at most 80 characters. These are + * descriptive values, but _may_ be used as part of credentials (e.g. for a + * "user" account, this can be the username). + */ +opaque type AccountName = String + +object AccountName: + private val regex: Regex = """^.{1,80}$""".r + + /** Validate the candidate string. Account names may be a maximum of 80 + * characters. Name characters are not restricted. + * + * @param candidate + * The candidate string. + * @return + * Some new [[AccountName]] instance, or `None` if the candidate is not + * valid. + */ + def validate(candidate: String): Option[AccountName] = + if regex.matches(candidate) then Some(candidate) else None + + given CanEqual[AccountName, AccountName] = CanEqual.derived + + given Eq[AccountName] = ( + x, + y + ) => x == y + + given Show[AccountName] = an => an + + extension (accountName: AccountName) + /** @return + * The underlying string value. + */ + def unwrap(): String = accountName + +end AccountName diff --git a/modules/model/src/main/scala/gs/smolban/model/account/AccountStatus.scala b/modules/model/src/main/scala/gs/smolban/model/account/AccountStatus.scala new file mode 100644 index 0000000..59e01f6 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/AccountStatus.scala @@ -0,0 +1,63 @@ +package gs.smolban.model.account + +import cats.Eq +import cats.Show + +/** Enumeration that describes the status of an [[Account]]. + * + * Smolban accounts are either active or not. They cannot be deleted - to + * preserve lineage of all operations. + */ +sealed abstract class AccountStatus(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: AccountStatus => name == other.name + case _ => false + + /** @inheritDocs + */ + override def toString(): String = name + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + +object AccountStatus: + + given CanEqual[AccountStatus, AccountStatus] = CanEqual.derived + + given Eq[AccountStatus] = ( + x, + y + ) => x == y + + given Show[AccountStatus] = _.name + + /** The account is active and can be used freely. + */ + case object Active extends AccountStatus("active") + + /** The account is not active and cannot be used unless reactivated. + */ + case object Inactive extends AccountStatus("inactive") + + /** List of all valid account types. + */ + val All: List[AccountStatus] = + List(Active, Inactive) + + /** Parse the given string as an [[AccountStatus]]. + * + * @param candidate + * The string to parse. + * @return + * Some account status value, or `None` if the given string is not a valid + * account status. + */ + def parse(candidate: String): Option[AccountStatus] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end AccountStatus diff --git a/modules/model/src/main/scala/gs/smolban/model/account/AccountType.scala b/modules/model/src/main/scala/gs/smolban/model/account/AccountType.scala new file mode 100644 index 0000000..a74f603 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/AccountType.scala @@ -0,0 +1,64 @@ +package gs.smolban.model.account + +import cats.Eq +import cats.Show + +/** Enumeration that describes the supported account types in Smolban. + */ +sealed abstract class AccountType(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: AccountType => name == other.name + case _ => false + + /** @inheritDocs + */ + override def toString(): String = name + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + +object AccountType: + + given CanEqual[AccountType, AccountType] = CanEqual.derived + + given Eq[AccountType] = ( + x, + y + ) => x == y + + given Show[AccountType] = _.name + + /** Regular human users. Typically access Smolban via some UI. + */ + case object User extends AccountType("user") + + /** Service accounts. + */ + case object Service extends AccountType("service_account") + + /** AI agents. + */ + case object Agent extends AccountType("ai_agent") + + /** List of all valid account types. + */ + val All: List[AccountType] = + List(User, Service, Agent) + + /** Parse the given string as an [[AccountType]]. + * + * @param candidate + * The string to parse. + * @return + * Some account type value, or `None` if the given string is not a valid + * account type. + */ + def parse(candidate: String): Option[AccountType] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end AccountType diff --git a/modules/model/src/main/scala/gs/smolban/model/account/Permission.scala b/modules/model/src/main/scala/gs/smolban/model/account/Permission.scala new file mode 100644 index 0000000..fefd1fc --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/Permission.scala @@ -0,0 +1,174 @@ +package gs.smolban.model.account + +import cats.Eq +import cats.Show + +/** Enumeration that describes all permissions supported by Smolban. + * + * In Smolban: all users may read all tickets. Smolban does not have the notion + * of private tickets. + * + * @param name + * The unique name of the permission. + */ +sealed abstract class Permission(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Permission => name == other.name + case _ => false + + /** @inheritDocs + */ + override def toString(): String = name + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + +object Permission: + + given CanEqual[Permission, Permission] = CanEqual.derived + + given Eq[Permission] = ( + x, + y + ) => x == y + + given Show[Permission] = _.name + + /** Permission to manage tickets within groups. + * + * ## Scope + * + * - Global + * - Group + * + * ## Capabilities + * + * - Create tickets within the given groups. + * - Modify tickets within the given groups. + */ + case object GroupMember extends Permission("group_member") + + /** Permission to manage groups. Superset of `GroupMember`. + * + * ## Scope + * + * - Global + * - Group + * + * ## Capabilities + * + * - Create tickets within the given groups. + * - Modify tickets within the given groups. + * - Modify group attributes. + */ + case object ManageGroup extends Permission("manage_group") + + /** Global permission to create groups. + */ + case object CreateGroup extends Permission("create_group") + + /** Permission to manage user accounts. + * + * ## Scope + * + * - Global + * + * ## Capabilities + * + * - Deactivate user accounts. + * - Revoke user credentials. + * - Assign permissions to user accounts. + * + * Note: Only administrators can reactivate accounts. + */ + case object ManageUserAccount extends Permission("manage_user_account") + + /** Global permission to create users. + */ + case object CreateUserAccount extends Permission("create_user_account") + + /** Permission to manage service accounts. + * + * ## Scope + * + * - Global + * + * Users who create service accounts always have permission to manage their + * own service accounts. + * + * ## Capabilities + * + * - Deactivate service accounts. + * - Revoke service account credentials. + * - Provision new service account credentials. + * - Assign permissions to service accounts. + * + * Note: Only administrators can reactivate accounts. + */ + case object ManageServiceAccount extends Permission("manage_service_account") + + /** Global permission to create service accounts. + */ + case object CreateServiceAccount extends Permission("create_service_account") + + /** Permission to manage AI agent accounts. + * + * ## Scope + * + * - Global + * + * Users who create AI agent accounts always have permission to manage their + * own AI agent accounts. + * + * ## Capabilities + * + * - Deactivate agent accounts. + * - Revoke agent account credentials. + * - Provision new agent account credentials. + * - Assign permissions to agent accounts. + * + * Note: Only administrators can reactivate accounts. + */ + case object ManageAgentAccount extends Permission("manage_agent_account") + + /** Global permission to create AI agent accounts. + */ + case object CreateAgentAccount extends Permission("create_agent_account") + + /** Global permission to perform any action. + */ + case object Admin extends Permission("admin") + + /** List of all valid permissions. + */ + val All: List[Permission] = + List( + GroupMember, + ManageGroup, + CreateGroup, + ManageUserAccount, + CreateUserAccount, + ManageServiceAccount, + CreateServiceAccount, + ManageAgentAccount, + CreateAgentAccount, + Admin + ) + + /** Parse the given string as an [[Permission]]. + * + * @param candidate + * The string to parse. + * @return + * Some account status value, or `None` if the given string is not a valid + * account status. + */ + def parse(candidate: String): Option[Permission] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end Permission diff --git a/modules/model/src/main/scala/gs/smolban/model/account/PermissionSet.scala b/modules/model/src/main/scala/gs/smolban/model/account/PermissionSet.scala new file mode 100644 index 0000000..52f528e --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/account/PermissionSet.scala @@ -0,0 +1,15 @@ +package gs.smolban.model.account + +import gs.smolban.model.group.GroupName + +/** Defines the complete set of scoped [[Permission]] for some [[Account]]. + * + * @param global + * The set of global permissions. + * @param group + * The set of permissions for each assigned group. + */ +case class PermissionSet( + global: Set[Permission], + group: Map[GroupName, Set[Permission]] +) 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 new file mode 100644 index 0000000..a40d33a --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/group/Group.scala @@ -0,0 +1,36 @@ +package gs.smolban.model.group + +import cats.Show +import gs.smolban.model.account.AccountId +import gs.smolban.model.metadata.CreatedAt +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 + * The unique name for the group. Used in URLs. + * @param createdAt + * The instant at which this group was created. + * @param creator + * Uniquely identifies the [[Account]] which created this Group. + */ +case class Group( + name: GroupName, + title: Title, + description: String, + createdAt: CreatedAt, + creator: AccountId +) + +object Group: + + given CanEqual[Group, Group] = CanEqual.derived + + given Show[Group] = _.name.unwrap() + +end Group diff --git a/modules/model/src/main/scala/gs/smolban/model/group/GroupName.scala b/modules/model/src/main/scala/gs/smolban/model/group/GroupName.scala new file mode 100644 index 0000000..6532785 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/group/GroupName.scala @@ -0,0 +1,51 @@ +package gs.smolban.model.group + +import cats.Eq +import cats.Show +import scala.util.matching.Regex + +/** Unique name for a [[Group]]. This is an opaque type for a String. + * + * ## Requirements + * + * - Starts with a character `A-Z` + * - Only contains uppercase letters, numbers, and underscores. + * - At least 2 characters. + * - At most 80 characters. + * + * Regular Expression: `^[A-Z][A-Z0-9\_]{1,79}$` + */ +opaque type GroupName = String + +object GroupName: + + private val regex: Regex = """^[A-Z][A-Z0-9\_]{1,79}$""".r + + /** Instantiate a new [[GroupName]]. + * + * @param name + * The underlying String. + * @return + * The new [[GroupName]] instance. + */ + def validate(name: String): Option[GroupName] = + if regex.matches(name) then Some(name) else None + + given CanEqual[GroupName, GroupName] = CanEqual.derived + + given Eq[GroupName] = ( + x, + y + ) => x == y + + given Show[GroupName] = gn => gn + + extension (name: GroupName) + /** Unwrap this Group name. + * + * @return + * The underlying String value. + */ + def unwrap(): String = name + +end GroupName diff --git a/modules/model/src/main/scala/gs/smolban/model/CreatedAt.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala similarity index 71% rename from modules/model/src/main/scala/gs/smolban/model/CreatedAt.scala rename to modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala index 876c260..998536e 100644 --- a/modules/model/src/main/scala/gs/smolban/model/CreatedAt.scala +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala @@ -1,6 +1,7 @@ -package gs.smolban.model +package gs.smolban.model.metadata import cats.Show +import java.time.Clock import java.time.Instant /** Describes an instant at which something was created. Opaque type for @@ -19,6 +20,15 @@ object CreatedAt: */ def apply(timestamp: Instant): CreatedAt = timestamp + /** Instantiate a new [[CreatedAt]] representing the current instant. + * + * @param clock + * The clock used to calculate instants. + * @return + * The new [[CreatedAt]] instance. + */ + def now(clock: Clock): CreatedAt = clock.instant() + /** Instantiate a new [[CreatedAt]]. * * @param millis @@ -33,6 +43,13 @@ object CreatedAt: given Show[CreatedAt] = _.toInstant().toString() extension (createdAt: CreatedAt) + /** Unwrap this value. + * + * @return + * The underlying `Instant` value. + */ + def unwrap(): Instant = createdAt + /** Unwrap this value. * * @return diff --git a/modules/model/src/main/scala/gs/smolban/model/metadata/Description.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/Description.scala new file mode 100644 index 0000000..8c074ac --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/Description.scala @@ -0,0 +1,37 @@ +package gs.smolban.model.metadata + +import cats.Eq +import cats.Show + +/** Opaque type which represents any description. Descriptions are arbitrary + * strings. + */ +opaque type Description = String + +object Description: + + /** Instantiate a new description. + * + * @param value + * The description text. + * @return + * The new [[Description]] instance. + */ + def apply(value: String): Description = value + + given CanEqual[Description, Description] = CanEqual.derived + + given Eq[Description] = ( + x, + y + ) => x == y + + given Show[Description] = t => t + + extension (tag: Description) + /** @return + * The underlying string value. + */ + def unwrap(): String = tag + +end Description diff --git a/modules/model/src/main/scala/gs/smolban/model/metadata/Tag.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/Tag.scala new file mode 100644 index 0000000..1f28a89 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/Tag.scala @@ -0,0 +1,31 @@ +package gs.smolban.model.metadata + +import cats.Eq +import cats.Show + +/** Represents a smolban "tag", or arbitrary string descriptor. This object + * captures additional information about the tag from the system perspective. + * + * @param value + * The unique value of this tag. + * @param createdAt + * The instant this tag was created. + */ +case class Tag( + value: TagValue, + createdAt: CreatedAt +): + override def toString(): String = value.toString() + +object Tag: + + given CanEqual[Tag, Tag] = CanEqual.derived + + given Eq[TagValue] = ( + x, + y + ) => x == y + + given Show[Tag] = _.value.unwrap() + +end Tag diff --git a/modules/model/src/main/scala/gs/smolban/model/metadata/TagValue.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/TagValue.scala new file mode 100644 index 0000000..350afba --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/TagValue.scala @@ -0,0 +1,38 @@ +package gs.smolban.model.metadata + +import cats.Eq +import cats.Show + +/** Opaque type which represents a [[Tag]] value. These values are non-empty, + * arbitrary strings. + */ +opaque type TagValue = String + +object TagValue: + + /** Validate the candidate string - TagValues must be non-empty. + * + * @param candidate + * The candidate string. + * @return + * The valid value, or `None` if an empty string was given. + */ + def validate(candidate: String): Option[TagValue] = + if candidate.isEmpty() then None else Some(candidate) + + given CanEqual[TagValue, TagValue] = CanEqual.derived + + given Eq[TagValue] = ( + x, + y + ) => x == y + + given Show[TagValue] = t => t + + extension (tag: TagValue) + /** @return + * The underlying string value. + */ + def unwrap(): String = tag + +end TagValue diff --git a/modules/model/src/main/scala/gs/smolban/model/metadata/Title.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/Title.scala new file mode 100644 index 0000000..1459e0e --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/Title.scala @@ -0,0 +1,42 @@ +package gs.smolban.model.metadata + +import cats.Eq +import cats.Show +import scala.util.matching.Regex + +/** Opaque type which represents the title of some object. These values are + * non-empty, arbitrary strings, with at most 120 characters. They are intended + * for providing brief summaries of intent to users. + */ +opaque type Title = String + +object Title: + private val regex: Regex = """^.{1,120}$""".r + + /** Validate the candidate string. Titles may be a maximum of 120 characters. + * Title characters are not restricted. + * + * @param candidate + * The candidate string. + * @return + * Some new [[Title]] instance, or `None` if the candidate is not valid. + */ + def validate(candidate: String): Option[Title] = + if regex.matches(candidate) then Some(candidate) else None + + given CanEqual[Title, Title] = CanEqual.derived + + given Eq[Title] = ( + x, + y + ) => x == y + + given Show[Title] = t => t + + extension (tag: Title) + /** @return + * The underlying string value. + */ + def unwrap(): String = tag + +end Title diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/Comment.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/Comment.scala new file mode 100644 index 0000000..92c1959 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/Comment.scala @@ -0,0 +1,25 @@ +package gs.smolban.model.ticket + +import gs.smolban.model.metadata.CreatedAt + +/** Some textual comment on a [[Ticket]]. Comments are arbitrary text that + * communicate information about a ticket from some actor. + * + * - Comments are ordered by when they were created. + * - Comments _may_ be edited. + * - Comments _may_ be deleted. + * - Comments have limited size. + * - Comments do not have reply / relationship capabilities. + * + * @param id + * Globally unique identifier for this comment. + * @param content + * The content of the comment. + * @param createdAt + * Instant the comment was created. + */ +case class Comment( + id: CommentId, + content: CommentContent, + createdAt: CreatedAt +) diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/CommentContent.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/CommentContent.scala new file mode 100644 index 0000000..3383cf3 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/CommentContent.scala @@ -0,0 +1,44 @@ +package gs.smolban.model.ticket + +import cats.Eq +import cats.Show +import scala.util.matching.Regex + +/** Opaque type which represents the content of some [[Comment]]. These values + * are non-empty, arbitrary strings, with at most `8192` characters. + * + * This limitation may be customizable in the future. + */ +opaque type CommentContent = String + +object CommentContent: + private val regex: Regex = """^.{1,8192}$""".r + + /** Validate the candidate string. Comments may be a maximum of 8192 + * characters. Characters are not restricted. + * + * @param candidate + * The candidate string. + * @return + * Some new [[CommentContent]] instance, or `None` if the candidate is not + * valid. + */ + def validate(candidate: String): Option[CommentContent] = + if regex.matches(candidate) then Some(candidate) else None + + given CanEqual[CommentContent, CommentContent] = CanEqual.derived + + given Eq[CommentContent] = ( + x, + y + ) => x == y + + given Show[CommentContent] = cc => cc + + extension (content: CommentContent) + /** @return + * The underlying string value. + */ + def unwrap(): String = content + +end CommentContent diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/CommentId.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/CommentId.scala new file mode 100644 index 0000000..a1d70dd --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/CommentId.scala @@ -0,0 +1,49 @@ +package gs.smolban.model.ticket + +import cats.Eq +import cats.Show +import gs.uuid.v0.UUID + +/** Uniquely identifies a _single semantic comment_ on a ticket. + */ +opaque type CommentId = UUID + +object CommentId: + + /** Instantiate a new [[CommentId]] from the given UUID. + * + * @param value + * The UUID. + * @return + * The new [[CommentId]]. + */ + def apply(value: UUID): CommentId = value + + /** Generate a new [[CommentId]] using the UUIDv7 algorithm. + * + * @return + * The new [[CommentId]]. + */ + def generate(): CommentId = UUID.v7() + + given CanEqual[CommentId, CommentId] = CanEqual.derived + + given Show[CommentId] = _.unwrap().withoutDashes() + + given Eq[CommentId] = ( + x, + y + ) => x == y + + extension (commentId: CommentId) + /** @return + * The underlying UUID value. + */ + def unwrap(): UUID = commentId + + /** @return + * The underlying UUID value. + */ + def toUUID(): UUID = commentId + +end CommentId diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/Ticket.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/Ticket.scala new file mode 100644 index 0000000..abdf880 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/Ticket.scala @@ -0,0 +1,70 @@ +package gs.smolban.model.ticket + +import cats.Eq +import cats.Show +import cats.syntax.all.* +import gs.smolban.model.account.AccountId +import gs.smolban.model.group.GroupName +import gs.smolban.model.metadata.CreatedAt +import gs.smolban.model.metadata.Description +import gs.smolban.model.metadata.TagValue +import gs.smolban.model.metadata.Title +import java.util.Objects + +/** Tickets represent some tracked work. + * + * TODO: Comments, Attachments, Watching + * + * @param number + * Unique number _within_ the [[Group]] Typically ascending integers. + * @param group + * Unique identifier of the [[Group]] that owns this ticket. + * @param createdAt + * The instant at which this ticket was created. + * @param title + * Arbitrary string title of the ticket. + * @param description + * Markdown contents of the ticket. + * @param tags + * List of [[Tag]] applied to this ticket. + * @param status + * Current [[Ticket.Status]] of this ticket. + * @param creator + * Uniquely identifes the [[Account]] which created this ticket. + * @param creator + * Uniquely identifes the [[Account]] to which this ticket is assigned. + */ +case class Ticket( + number: TicketNumber, + group: GroupName, + createdAt: CreatedAt, + title: Title, + description: Description, + tags: List[TagValue], + status: TicketStatus, + creator: AccountId, + assignee: Option[AccountId] +): + lazy val reference: TicketReference = TicketReference(group, number) + + override def toString(): String = s"${group.show}-${number.show}" + + override def equals(obj: Any): Boolean = + obj match + case other: Ticket => (group == other.group) && (number == other.number) + case _ => false + + override def hashCode(): Int = Objects.hash(group, number) + +object Ticket: + + given CanEqual[Ticket, Ticket] = CanEqual.derived + + given Show[Ticket] = _.toString() + + given Eq[Ticket] = ( + x, + y + ) => x == y + +end Ticket diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/TicketNumber.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketNumber.scala new file mode 100644 index 0000000..ed473e3 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketNumber.scala @@ -0,0 +1,41 @@ +package gs.smolban.model.ticket + +import cats.Show + +/** Unique identifier - relative to some [[gs.smolban.model.group.Group]] - for + * a [[Ticket]]. This is an opaque type for a `Long`. In general, [[Ticket]] + * identifiers are sequences within a group. + */ +opaque type TicketNumber = Long + +object TicketNumber: + + /** Instantiate a new [[TicketNumber]]. + * + * @param number + * The underlying Long. + * @return + * The new [[TicketNumber]] instance. + */ + def apply(number: Long): TicketNumber = number + + given CanEqual[TicketNumber, TicketNumber] = CanEqual.derived + + given Show[TicketNumber] = _.toString() + + extension (number: TicketNumber) + /** Unwrap this Ticket ID. + * + * @return + * The underlying Long value. + */ + def unwrap(): Long = number + + /** Unwrap this Ticket ID. + * + * @return + * The underlying Long value. + */ + def toLong(): Long = number + +end TicketNumber diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/TicketReference.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketReference.scala new file mode 100644 index 0000000..1096e46 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketReference.scala @@ -0,0 +1,15 @@ +package gs.smolban.model.ticket + +import gs.smolban.model.group.GroupName + +/** Composite reference that uniquely addresses any [[Ticket]]. + * + * @param number + * The ticket's unique identifier within the [[Group]]. + * @param group + * The unique identifier of the [[Group]]. + */ +case class TicketReference( + group: GroupName, + number: TicketNumber +) diff --git a/modules/model/src/main/scala/gs/smolban/model/ticket/TicketStatus.scala b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketStatus.scala new file mode 100644 index 0000000..3f54f75 --- /dev/null +++ b/modules/model/src/main/scala/gs/smolban/model/ticket/TicketStatus.scala @@ -0,0 +1,70 @@ +package gs.smolban.model.ticket + +import cats.Eq +import cats.Show + +/** Enumeration that describes the status of a [[Ticket]] in Smolban. + */ +sealed abstract class TicketStatus(val name: String): + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: TicketStatus => name == other.name + case _ => false + + /** @inheritDocs + */ + override def toString(): String = name + + /** @inheritDocs + */ + override def hashCode(): Int = name.hashCode() + +object TicketStatus: + + given CanEqual[TicketStatus, TicketStatus] = CanEqual.derived + + given Eq[TicketStatus] = ( + x, + y + ) => x == y + + given Show[TicketStatus] = _.name + + /** This ticket is not currently active. + */ + case object Inactive extends TicketStatus("inactive") + + /** This ticket is being worked on actively. In progress tickets can be move + * to inactive, completed, or canceled. + */ + case object InProgress extends TicketStatus("in_progress") + + /** This ticket was driven to completion. The work is done. Once in this + * state, the status may no longer change. + */ + case object Complete extends TicketStatus("complete") + + /** This ticket was canceled for some reason. Once in this state, the status + * may no longer change. + */ + case object Canceled extends TicketStatus("canceled") + + /** List of all valid status values. + */ + val All: List[TicketStatus] = + List(Inactive, InProgress, Complete, Canceled) + + /** Parse the given string as a status. + * + * @param candidate + * The string to parse. + * @return + * Some status value, or `None` if the given string is not a valid status. + */ + def parse(candidate: String): Option[TicketStatus] = + All.find(_.name.equalsIgnoreCase(candidate)) + +end TicketStatus diff --git a/project/build.properties b/project/build.properties index ee4c672..bcdf773 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.1 +sbt.version=1.12.1 diff --git a/project/plugins.sbt b/project/plugins.sbt index 7daef75..18a6411 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -28,6 +28,6 @@ externalResolvers := Seq( "Garrity Software Releases" at "https://maven.garrity.co/gs" ) -addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8") -addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0") +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0") addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")