(patch) Build out model #2
11 changed files with 457 additions and 6 deletions
|
@ -44,7 +44,8 @@ jobs:
|
||||||
sbtn coverageOff
|
sbtn coverageOff
|
||||||
sbtn clean
|
sbtn clean
|
||||||
sbtn api/calVerWriteVersionToFile
|
sbtn api/calVerWriteVersionToFile
|
||||||
sbtn compile
|
selected_version="$(cat .version)"
|
||||||
|
echo "PRODUCING A RELEASE IS CURRENTLY TURNED OFF -- $selected_version"
|
||||||
- name: 'Create Git Tag'
|
- name: 'Create Git Tag'
|
||||||
run: |
|
run: |
|
||||||
selected_version="$(cat .version)"
|
selected_version="$(cat .version)"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
val scala3: String = "3.4.1"
|
val scala3: String = "3.4.2"
|
||||||
|
|
||||||
ThisBuild / scalaVersion := scala3
|
ThisBuild / scalaVersion := scala3
|
||||||
ThisBuild / gsProjectName := "smolban"
|
ThisBuild / gsProjectName := "smolban"
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
package gs.smolban.model
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
import gs.smolban.model.users.User
|
||||||
|
|
||||||
|
opaque type CreatedBy = User.Id
|
||||||
|
|
||||||
|
object CreatedBy:
|
||||||
|
|
||||||
|
def apply(timestamp: User.Id): CreatedBy = timestamp
|
||||||
|
|
||||||
|
given CanEqual[CreatedBy, CreatedBy] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[CreatedBy] = _.toUserId().toUUID().withoutDashes()
|
||||||
|
|
||||||
|
extension (createdAt: CreatedBy)
|
||||||
|
/** Unwrap this value.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying `User.Id` value.
|
||||||
|
*/
|
||||||
|
def toUserId(): User.Id = createdAt
|
||||||
|
|
||||||
|
end CreatedBy
|
|
@ -13,15 +13,20 @@ import gs.uuid.v0.UUID
|
||||||
* The unique slug for the group.
|
* The unique slug for the group.
|
||||||
* @param createdAt
|
* @param createdAt
|
||||||
* The instant at which this group was created.
|
* The instant at which this group was created.
|
||||||
|
* @param createdBy
|
||||||
|
* The unique identifier of the user who created this group.
|
||||||
*/
|
*/
|
||||||
case class Group(
|
case class Group(
|
||||||
id: Group.Id,
|
id: Group.Id,
|
||||||
slug: Slug,
|
slug: Slug,
|
||||||
createdAt: CreatedAt
|
createdAt: CreatedAt,
|
||||||
|
createdBy: CreatedBy
|
||||||
)
|
)
|
||||||
|
|
||||||
object Group:
|
object Group:
|
||||||
|
|
||||||
|
given CanEqual[Group, Group] = CanEqual.derived
|
||||||
|
|
||||||
/** Unique identifier for a [[Group]]. This is an opaque type for a UUID.
|
/** Unique identifier for a [[Group]]. This is an opaque type for a UUID.
|
||||||
*/
|
*/
|
||||||
opaque type Id = UUID
|
opaque type Id = UUID
|
||||||
|
|
21
modules/model/src/main/scala/gs/smolban/model/Tag.scala
Normal file
21
modules/model/src/main/scala/gs/smolban/model/Tag.scala
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package gs.smolban.model
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Opaque type for a String that represents a unique `Tag` in Smolban. Tags are
|
||||||
|
* just arbitrary non-empty strings which can be used to annotate [[Ticket]].
|
||||||
|
*
|
||||||
|
* Tags are defined _globally_ in Smolban.
|
||||||
|
*/
|
||||||
|
opaque type Tag = String
|
||||||
|
|
||||||
|
object Tag:
|
||||||
|
|
||||||
|
def validate(candidate: String): Option[Tag] =
|
||||||
|
if candidate.isEmpty() then None else Some(candidate)
|
||||||
|
|
||||||
|
given CanEqual[Tag, Tag] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Tag] = t => t
|
||||||
|
|
||||||
|
end Tag
|
|
@ -1,11 +1,43 @@
|
||||||
package gs.smolban.model
|
package gs.smolban.model
|
||||||
|
|
||||||
import cats.Show
|
import cats.Show
|
||||||
|
import gs.smolban.model.users.User
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/** Tickets represent some tracked work.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* Unique identifier _within_ the [[Group]].
|
||||||
|
* @param group
|
||||||
|
* Unique identifier of the [[Group]] that owns this ticket.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this ticket was created.
|
||||||
|
* @param createdBy
|
||||||
|
* The unique identifier of the [[User]] who created this ticket.
|
||||||
|
* @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.
|
||||||
|
* @param assignee
|
||||||
|
* If set, this ticket is assigned to a specific user.
|
||||||
|
*/
|
||||||
case class Ticket(
|
case class Ticket(
|
||||||
id: Long,
|
id: Ticket.Id,
|
||||||
group: Group.Id,
|
group: Group.Id,
|
||||||
createdAt: CreatedAt
|
createdAt: CreatedAt,
|
||||||
|
createdBy: CreatedBy,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
tags: List[Tag],
|
||||||
|
status: Ticket.Status,
|
||||||
|
statusHistory: List[(Ticket.Status, Instant)],
|
||||||
|
assignee: Option[User.Id]
|
||||||
)
|
)
|
||||||
|
|
||||||
object Ticket:
|
object Ticket:
|
||||||
|
@ -41,4 +73,47 @@ object Ticket:
|
||||||
|
|
||||||
end Id
|
end Id
|
||||||
|
|
||||||
|
/** 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
|
end Ticket
|
||||||
|
|
104
modules/model/src/main/scala/gs/smolban/model/users/Role.scala
Normal file
104
modules/model/src/main/scala/gs/smolban/model/users/Role.scala
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
package gs.smolban.model.users
|
||||||
|
|
||||||
|
/** Roles define what each [[User]] is allowed to do.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of the role.
|
||||||
|
* @param scope
|
||||||
|
* The [[Scope]] to which the role applies.
|
||||||
|
*/
|
||||||
|
sealed abstract class Role(
|
||||||
|
val name: String,
|
||||||
|
val scope: Scope
|
||||||
|
):
|
||||||
|
|
||||||
|
override def toString(): String =
|
||||||
|
s"[$name]${Role.Delimiter}$scope"
|
||||||
|
|
||||||
|
object Role:
|
||||||
|
|
||||||
|
val Delimiter: String = ":"
|
||||||
|
|
||||||
|
/** Administrator for the API. Can perform any operation through the API.
|
||||||
|
*/
|
||||||
|
case object ApiAdmin extends Role(s"api${Delimiter}admin", Scope.ApiGlobal)
|
||||||
|
|
||||||
|
/** Global read access to the API.
|
||||||
|
*/
|
||||||
|
case object ApiGlobalReader
|
||||||
|
extends Role(s"api${Delimiter}reader", Scope.ApiGlobal)
|
||||||
|
|
||||||
|
/** Global write access to the API.
|
||||||
|
*/
|
||||||
|
case object ApiGlobalWriter
|
||||||
|
extends Role(s"api${Delimiter}writer", Scope.ApiGlobal)
|
||||||
|
|
||||||
|
/** Administrator for a specific [[gs.smolban.model.Group]] via the API. Can
|
||||||
|
* perform any operation through the API for the indicated group.
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class ApiGroupAdmin(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"api${Delimiter}group_admin", Scope.ApiGroup(groupId))
|
||||||
|
|
||||||
|
/** Grants API read access for a specific [[gs.smolban.model.Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class ApiGroupReader(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"api${Delimiter}group_reader", Scope.ApiGroup(groupId))
|
||||||
|
|
||||||
|
/** Grants API write access for a specific [[gs.smolban.model.Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class ApiGroupWriter(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"api${Delimiter}group_writer", Scope.ApiGroup(groupId))
|
||||||
|
|
||||||
|
/** Administrator for the UI. Can perform any operation through the UI.
|
||||||
|
*/
|
||||||
|
case object UiAdmin extends Role(s"ui${Delimiter}admin", Scope.UiGlobal)
|
||||||
|
|
||||||
|
/** Global read access to the UI.
|
||||||
|
*/
|
||||||
|
case object UiGlobalReader extends Role("ui:reader", Scope.UiGlobal)
|
||||||
|
|
||||||
|
/** Global write access to the UI.
|
||||||
|
*/
|
||||||
|
case object UiGlobalWriter extends Role("ui:writer", Scope.UiGlobal)
|
||||||
|
|
||||||
|
/** Administrator for a specific [[gs.smolban.model.Group]] via the UI. Can
|
||||||
|
* perform any operation through the UI for the indicated group.
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class UiGroupAdmin(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"ui${Delimiter}group_admin", Scope.UiGroup(groupId))
|
||||||
|
|
||||||
|
/** Grants UI read access for a specific [[gs.smolban.model.Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class UiGroupReader(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"ui${Delimiter}group_reader", Scope.UiGroup(groupId))
|
||||||
|
|
||||||
|
/** Grants UI write access for a specific [[gs.smolban.model.Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier for the group to which this role is scoped.
|
||||||
|
*/
|
||||||
|
case class UiGroupWriter(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Role(s"ui${Delimiter}group_writer", Scope.UiGroup(groupId))
|
||||||
|
|
||||||
|
end Role
|
|
@ -0,0 +1,78 @@
|
||||||
|
package gs.smolban.model.users
|
||||||
|
|
||||||
|
import cats.syntax.all.*
|
||||||
|
|
||||||
|
/** Describes a _Scope_, or service of Smolban, to which a user can have access.
|
||||||
|
*
|
||||||
|
* Scopes consist of two parts:
|
||||||
|
*
|
||||||
|
* - `service`: The name of a service Smolban offers.
|
||||||
|
* - `name`: The name of the scope within the given service.
|
||||||
|
*
|
||||||
|
* @param service
|
||||||
|
* The name of a service Smolban offers (e.g. `api`, `ui`)
|
||||||
|
* @param name
|
||||||
|
* The name of the scope within the service.
|
||||||
|
*/
|
||||||
|
sealed abstract class Scope(
|
||||||
|
val service: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
object Scope:
|
||||||
|
|
||||||
|
given CanEqual[Scope, Scope] = CanEqual.derived
|
||||||
|
|
||||||
|
object Services:
|
||||||
|
|
||||||
|
val Api: String = "api"
|
||||||
|
val Ui: String = "ui"
|
||||||
|
|
||||||
|
end Services
|
||||||
|
|
||||||
|
object Names:
|
||||||
|
|
||||||
|
val Global: String = "global"
|
||||||
|
val Group: String = "group"
|
||||||
|
|
||||||
|
end Names
|
||||||
|
|
||||||
|
val Delimiter: String = ":"
|
||||||
|
|
||||||
|
/** The `api:global` scope covers the Smolban API across all groups.
|
||||||
|
*/
|
||||||
|
case object ApiGlobal extends Scope(Services.Api, Names.Global):
|
||||||
|
override def toString(): String = s"[$service$Delimiter$name]"
|
||||||
|
|
||||||
|
/** The `api:group:<id>` scope refers the the Smolban API for a specific
|
||||||
|
* [[Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier of the [[Group]].
|
||||||
|
*/
|
||||||
|
case class ApiGroup(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Scope(Services.Api, Names.Group):
|
||||||
|
|
||||||
|
override def toString(): String =
|
||||||
|
s"[$service$Delimiter$name$Delimiter${groupId.show}]"
|
||||||
|
|
||||||
|
/** The `ui:global` scope covers the Smolban UI across all groups.
|
||||||
|
*/
|
||||||
|
case object UiGlobal extends Scope(Services.Ui, Names.Global):
|
||||||
|
override def toString(): String = s"[$service$Delimiter$name]"
|
||||||
|
|
||||||
|
/** The `ui:group:<id>` scope refers the the Smolban UI for a specific
|
||||||
|
* [[Group]].
|
||||||
|
*
|
||||||
|
* @param groupId
|
||||||
|
* The unique identifier of the [[Group]].
|
||||||
|
*/
|
||||||
|
case class UiGroup(
|
||||||
|
groupId: gs.smolban.model.Group.Id
|
||||||
|
) extends Scope(Services.Ui, Names.Group):
|
||||||
|
|
||||||
|
override def toString(): String =
|
||||||
|
s"[$service$Delimiter$name$Delimiter${groupId.show}]"
|
||||||
|
|
||||||
|
end Scope
|
|
@ -0,0 +1,96 @@
|
||||||
|
package gs.smolban.model.users
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
|
||||||
|
/** Represents a user that can interact with Smolban.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* Unique identifier of this user.
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this user was created.
|
||||||
|
* @param username
|
||||||
|
* The (unique) [[Username]] of this user.
|
||||||
|
* @param designation
|
||||||
|
* The [[User.Designation]] of this user.
|
||||||
|
* @param roles
|
||||||
|
* List of [[Role]] assigned to this user.
|
||||||
|
* @param status
|
||||||
|
* The current [[User.Status]] of this user.
|
||||||
|
*/
|
||||||
|
case class User(
|
||||||
|
id: User.Id,
|
||||||
|
createdAt: CreatedAt,
|
||||||
|
username: Username,
|
||||||
|
designation: User.Designation,
|
||||||
|
roles: List[Role],
|
||||||
|
status: User.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
object User:
|
||||||
|
|
||||||
|
given CanEqual[User, User] = CanEqual.derived
|
||||||
|
|
||||||
|
/** Unique identifier for a [[User]]. This is an opaque type for a UUID.
|
||||||
|
*/
|
||||||
|
opaque type Id = UUID
|
||||||
|
|
||||||
|
object Id:
|
||||||
|
|
||||||
|
/** Instantiate a new [[User.Id]].
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The underlying UUID.
|
||||||
|
* @return
|
||||||
|
* The new [[User.Id]] instance.
|
||||||
|
*/
|
||||||
|
def apply(id: UUID): Id = id
|
||||||
|
|
||||||
|
given CanEqual[Id, Id] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Id] = _.toUUID().withoutDashes()
|
||||||
|
|
||||||
|
extension (id: Id)
|
||||||
|
/** Unwrap this User ID.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying UUID value.
|
||||||
|
*/
|
||||||
|
def toUUID(): UUID = id
|
||||||
|
|
||||||
|
end Id
|
||||||
|
|
||||||
|
/** Enumeration that describes the designation of a [[User]].
|
||||||
|
*/
|
||||||
|
sealed abstract class Designation(val name: String)
|
||||||
|
|
||||||
|
object Designation:
|
||||||
|
|
||||||
|
/** Regular users are typically human, and are expected to be using the Web
|
||||||
|
* UI and _possibly_ APIs.
|
||||||
|
*/
|
||||||
|
case object Regular extends Designation("regular")
|
||||||
|
|
||||||
|
/** Service users are intended for use by API-consuming services. They are
|
||||||
|
* not suitable for human/UI interactive use.
|
||||||
|
*/
|
||||||
|
case object Service extends Designation("service")
|
||||||
|
|
||||||
|
end Designation
|
||||||
|
|
||||||
|
/** Enumeration that describes the status of a [[User]] in Smolban.
|
||||||
|
*/
|
||||||
|
sealed abstract class Status(val name: String)
|
||||||
|
|
||||||
|
object Status:
|
||||||
|
|
||||||
|
given CanEqual[Status, Status] = CanEqual.derived
|
||||||
|
|
||||||
|
given Show[Status] = _.name
|
||||||
|
|
||||||
|
case object Active extends Status("active")
|
||||||
|
case object Suspended extends Status("suspended")
|
||||||
|
case object Off extends Status("off")
|
||||||
|
|
||||||
|
end User
|
|
@ -0,0 +1,47 @@
|
||||||
|
package gs.smolban.model.users
|
||||||
|
|
||||||
|
import cats.Show
|
||||||
|
|
||||||
|
/** Opaque type for String that represents a unique username in Smolban.
|
||||||
|
*/
|
||||||
|
opaque type Username = String
|
||||||
|
|
||||||
|
object Username:
|
||||||
|
|
||||||
|
/** In Smolban, a [[Username]] must be at most 32 characters long.
|
||||||
|
*/
|
||||||
|
val MaximumLength: Int = 32
|
||||||
|
|
||||||
|
/** In Smolban, a [[Username]] must be at least 3 characters long.
|
||||||
|
*/
|
||||||
|
val MinimumLength: Int = 3
|
||||||
|
|
||||||
|
given CanEqual[Username, Username] = CanEqual.derived
|
||||||
|
|
||||||
|
/** Validate some candidate string, producing a [[Username]] if valid. Smolban
|
||||||
|
* usernames must be:
|
||||||
|
*
|
||||||
|
* - At least 3 characters long.
|
||||||
|
* - At most 32 characters long.
|
||||||
|
* - Non-blank -- non-whitespace characters must be used.
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate string to evaluate.
|
||||||
|
* @return
|
||||||
|
* The [[Username]], or `None` if the candidate was invalid.
|
||||||
|
*/
|
||||||
|
def validate(candidate: String): Option[Username] =
|
||||||
|
if isValid(candidate) then Some(candidate) else None
|
||||||
|
|
||||||
|
private def isValid(candidate: String): Boolean =
|
||||||
|
isValidSize(candidate) && isNonBlank(candidate)
|
||||||
|
|
||||||
|
private def isValidSize(candidate: String): Boolean =
|
||||||
|
candidate.length() >= MinimumLength && candidate.length() <= MaximumLength
|
||||||
|
|
||||||
|
private def isNonBlank(candidate: String): Boolean =
|
||||||
|
!candidate.isBlank()
|
||||||
|
|
||||||
|
given Show[Username] = u => u
|
||||||
|
|
||||||
|
end Username
|
|
@ -29,5 +29,5 @@ externalResolvers := Seq(
|
||||||
)
|
)
|
||||||
|
|
||||||
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
|
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
|
||||||
addSbtPlugin("gs" % "sbt-garrity-software" % "0.2.0")
|
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
|
||||||
addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")
|
addSbtPlugin("gs" % "sbt-gs-calver" % "0.2.0")
|
||||||
|
|
Loading…
Add table
Reference in a new issue