some changes to initial scope (no users at all) and db updates and sql

This commit is contained in:
Pat Garrity 2024-08-08 22:20:56 -05:00
parent 41e9e797c1
commit fcc774390d
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
11 changed files with 121 additions and 369 deletions

View file

@ -0,0 +1,50 @@
-- sqlite3
CREATE TABLE IF NOT EXISTS groups(
id BIGINT PRIMARY KEY,
group_id BLOB NOT NULL,
slug TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id);
CREATE TABLE IF NOT EXISTS tags(
id BIGINT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(value);
CREATE TABLE IF NOT EXISTS tickets(
id BIGINT PRIMARY KEY,
ticket_number INTEGER NOT NULL,
group_id BLOB NOT NULL,
created_at DATETIME NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_number_group
ON tickets(group_id, ticket_number);
CREATE TABLE IF NOT EXISTS ticket_tags(
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_tags_ticket_tag
ON ticket_tags(ticket_id, tag_id);
CREATE TABLE IF NOT EXISTS ticket_history(
id BIGINT PRIMARY KEY,
ticket_id BIGINT NOT NULL,
status TEXT NOT NULL,
set_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ticket_history_order_ticket_set_at
ON ticket_history(ticket_id, set_at);

View file

@ -0,0 +1,11 @@
package gs.smolban.db
import cats.data.EitherT
import gs.smolban.model.Group
trait GroupDb[F[_]]:
def createGroup(group: Group): EitherT[F, DbError, Group]
def readGroup(id: Group.Id): F[Option[Group]]
def deleteGroup(id: Group.Id): F[Boolean]

View file

@ -0,0 +1,9 @@
package gs.smolban.db
import cats.data.EitherT
import gs.smolban.model.Tag
trait TagDb[F[_]]:
def createTag(tag: Tag.Value): EitherT[F, DbError, Tag]
def readTag(id: Tag.Id): F[Option[Tag]]
def deleteTag(id: Tag.Id): F[Boolean]

View file

@ -1,24 +0,0 @@
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

View file

@ -13,14 +13,11 @@ 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:

View file

@ -1,21 +1,62 @@
package gs.smolban.model package gs.smolban.model
import cats.Show import cats.Show
import java.time.Instant
/** Opaque type for a String that represents a unique `Tag` in Smolban. Tags are /** Represents a smolban "tag", or arbitrary string descriptor.
* just arbitrary non-empty strings which can be used to annotate [[Ticket]].
* *
* Tags are defined _globally_ in Smolban. * @param id
* The unique ID for this tag.
* @param value
* The unique value of this tag.
* @param createdAt
* The instant this tag was created.
*/ */
opaque type Tag = String // TODO: Make this have a unique id (long?) and value case class Tag(
id: Tag.Id,
value: Tag.Value,
createdAt: Instant
)
object Tag: object Tag:
def validate(candidate: String): Option[Tag] =
if candidate.isEmpty() then None else Some(candidate)
given CanEqual[Tag, Tag] = CanEqual.derived given CanEqual[Tag, Tag] = CanEqual.derived
given Show[Tag] = t => t /** 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()
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 end Tag

View file

@ -1,7 +1,6 @@
package gs.smolban.model package gs.smolban.model
import cats.Show import cats.Show
import gs.smolban.model.users.User
import java.time.Instant import java.time.Instant
/** Tickets represent some tracked work. /** Tickets represent some tracked work.
@ -12,8 +11,6 @@ import java.time.Instant
* Unique identifier of the [[Group]] that owns this ticket. * Unique identifier of the [[Group]] that owns this ticket.
* @param createdAt * @param createdAt
* The instant at which this ticket was created. * The instant at which this ticket was created.
* @param createdBy
* The unique identifier of the [[User]] who created this ticket.
* @param title * @param title
* Arbitrary string title of the ticket. * Arbitrary string title of the ticket.
* @param description * @param description
@ -24,20 +21,16 @@ import java.time.Instant
* Current [[Ticket.Status]] of this ticket. * Current [[Ticket.Status]] of this ticket.
* @param statusHistory * @param statusHistory
* Linear history of this ticket in terms of status changes. * 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: Ticket.Id, id: Ticket.Id,
group: Group.Id, group: Group.Id,
createdAt: CreatedAt, createdAt: CreatedAt,
createdBy: CreatedBy,
title: String, title: String,
description: String, description: String,
tags: List[Tag], tags: List[Tag],
status: Ticket.Status, status: Ticket.Status,
statusHistory: List[(Ticket.Status, Instant)], statusHistory: List[(Ticket.Status, Instant)]
assignee: Option[User.Id]
): ):
lazy val reference: Ticket.Reference = Ticket.Reference(id, group) lazy val reference: Ticket.Reference = Ticket.Reference(id, group)

View file

@ -1,104 +0,0 @@
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

View file

@ -1,78 +0,0 @@
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

View file

@ -1,96 +0,0 @@
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

View file

@ -1,47 +0,0 @@
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