so far from the db

This commit is contained in:
Pat Garrity 2026-01-29 22:08:13 -06:00
parent a5bea9c7de
commit 5d85d6e6ad
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
27 changed files with 1136 additions and 262 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]]
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
)

View file

@ -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

View file

@ -1 +1 @@
sbt.version=1.10.1
sbt.version=1.12.1

View file

@ -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")