Compare commits
6 commits
main
...
smolban-db
Author | SHA1 | Date | |
---|---|---|---|
a5bea9c7de | |||
ab16e3fdf0 | |||
fcc774390d | |||
41e9e797c1 | |||
1195bab53c | |||
456e625e82 |
22 changed files with 687 additions and 387 deletions
|
@ -38,10 +38,10 @@ val Deps = new {
|
||||||
}
|
}
|
||||||
|
|
||||||
val Gs = new {
|
val Gs = new {
|
||||||
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.2.4"
|
val Uuid: ModuleID = "gs" %% "gs-uuid-v0" % "0.3.0"
|
||||||
val Slug: ModuleID = "gs" %% "gs-slug-v0" % "0.1.3"
|
val Slug: ModuleID = "gs" %% "gs-slug-v0" % "0.1.3"
|
||||||
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.1.1"
|
val Config: ModuleID = "gs" %% "gs-config-v0" % "0.1.1"
|
||||||
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.1.1"
|
val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.0-RC1"
|
val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.0.0-RC1"
|
||||||
|
@ -75,6 +75,7 @@ lazy val model = project
|
||||||
|
|
||||||
lazy val db = project
|
lazy val db = project
|
||||||
.in(file("modules/db"))
|
.in(file("modules/db"))
|
||||||
|
.dependsOn(model)
|
||||||
.settings(sharedSettings)
|
.settings(sharedSettings)
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(name := s"${gsProjectName.value}-db")
|
.settings(name := s"${gsProjectName.value}-db")
|
||||||
|
@ -86,6 +87,7 @@ lazy val db = project
|
||||||
|
|
||||||
lazy val api = project
|
lazy val api = project
|
||||||
.in(file("modules/api"))
|
.in(file("modules/api"))
|
||||||
|
.dependsOn(model, db)
|
||||||
.settings(sharedSettings)
|
.settings(sharedSettings)
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(name := s"${gsProjectName.value}-api")
|
.settings(name := s"${gsProjectName.value}-api")
|
||||||
|
|
50
modules/db/src/main/resources/sql/1.sql
Normal file
50
modules/db/src/main/resources/sql/1.sql
Normal 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,
|
||||||
|
tag_value TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(tag_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);
|
14
modules/db/src/main/scala/gs/smolban/db/DbConstants.scala
Normal file
14
modules/db/src/main/scala/gs/smolban/db/DbConstants.scala
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
object DbConstants:
|
||||||
|
|
||||||
|
val UniqueConstraintFailed: List[Int] =
|
||||||
|
List(Sqlite.UniqueConstraintFailed)
|
||||||
|
|
||||||
|
object Sqlite:
|
||||||
|
|
||||||
|
val UniqueConstraintFailed: Int = 2067
|
||||||
|
|
||||||
|
end Sqlite
|
||||||
|
|
||||||
|
end DbConstants
|
34
modules/db/src/main/scala/gs/smolban/db/DbError.scala
Normal file
34
modules/db/src/main/scala/gs/smolban/db/DbError.scala
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import gs.slug.v0.Slug
|
||||||
|
import gs.smolban.model.Group
|
||||||
|
import gs.smolban.model.SmolbanError
|
||||||
|
import gs.smolban.model.Tag
|
||||||
|
import gs.smolban.model.Ticket
|
||||||
|
|
||||||
|
/** Parent type of all database errors in Smolban.
|
||||||
|
*/
|
||||||
|
sealed trait DbError extends SmolbanError
|
||||||
|
|
||||||
|
object DbError:
|
||||||
|
|
||||||
|
/** Returned if an attempt is made to insert a [[Ticket]], where the Ticket ID
|
||||||
|
* already exists for the specified [[gs.smolban.model.Group]].
|
||||||
|
*
|
||||||
|
* @param ref
|
||||||
|
* The Ticket Reference that was in conflict.
|
||||||
|
*/
|
||||||
|
case class TicketAlreadyExists(ref: Ticket.Reference) extends DbError
|
||||||
|
|
||||||
|
case class GroupAlreadyExists(
|
||||||
|
id: Group.Id,
|
||||||
|
slug: Slug
|
||||||
|
) extends DbError
|
||||||
|
|
||||||
|
case class TagAlreadyExists(
|
||||||
|
value: Tag.Value
|
||||||
|
) extends DbError
|
||||||
|
|
||||||
|
case class SilentInsertFailure(context: String) extends DbError
|
||||||
|
|
||||||
|
end DbError
|
35
modules/db/src/main/scala/gs/smolban/db/GroupDb.scala
Normal file
35
modules/db/src/main/scala/gs/smolban/db/GroupDb.scala
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import gs.smolban.model.Group
|
||||||
|
|
||||||
|
/** Database interface for [[Group]].
|
||||||
|
*/
|
||||||
|
trait GroupDb[F[_]]:
|
||||||
|
/** Create a new [[Group]] in the database.
|
||||||
|
*
|
||||||
|
* @param group
|
||||||
|
* The [[Group]] to store.
|
||||||
|
* @return
|
||||||
|
* The [[Group]] that was stored, or an error.
|
||||||
|
*/
|
||||||
|
def createGroup(group: Group): EitherT[F, DbError, Group]
|
||||||
|
|
||||||
|
/** Read the specified [[Group]] from the database.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier of the [[Group]] to retrieve.
|
||||||
|
* @return
|
||||||
|
* The [[Group]], or `None` if the specified ID does not exist.
|
||||||
|
*/
|
||||||
|
def readGroup(id: Group.Id): F[Option[Group]]
|
||||||
|
|
||||||
|
/** Delete the specified [[Group]]. Note that this operation deletes all
|
||||||
|
* [[Ticket]] associated with the group and should be used with care.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique identifier of the [[Group]] to delete.
|
||||||
|
* @return
|
||||||
|
* `true` if the group was deleted, `false` otherwise.
|
||||||
|
*/
|
||||||
|
def deleteGroup(id: Group.Id): F[Boolean]
|
39
modules/db/src/main/scala/gs/smolban/db/TagDb.scala
Normal file
39
modules/db/src/main/scala/gs/smolban/db/TagDb.scala
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.smolban.model.Tag
|
||||||
|
|
||||||
|
trait TagDb[F[_]]:
|
||||||
|
|
||||||
|
/** Create a new [[Tag]].
|
||||||
|
*
|
||||||
|
* @param tag
|
||||||
|
* The value of the tag to create (some arbitrary string).
|
||||||
|
* @param createdAt
|
||||||
|
* The instant at which this tag was created.
|
||||||
|
* @return
|
||||||
|
* The new [[Tag]], or an error if the value already exists.
|
||||||
|
*/
|
||||||
|
def createTag(
|
||||||
|
tag: Tag.Value,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): EitherT[F, DbError, Tag]
|
||||||
|
|
||||||
|
/** Get the value of some [[Tag]] by ID.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The internal identifier of the [[Tag]].
|
||||||
|
* @return
|
||||||
|
* The found [[Tag]], or `None` if the ID does not exist.
|
||||||
|
*/
|
||||||
|
def readTag(id: Tag.Id): F[Option[Tag]]
|
||||||
|
|
||||||
|
/** Delete a [[Tag]] by ID.
|
||||||
|
*
|
||||||
|
* @param id
|
||||||
|
* The unique ID of the [[Tag]] to delete.
|
||||||
|
* @return
|
||||||
|
* `true` if the [[Tag]] was deleted, `false` otherwise.
|
||||||
|
*/
|
||||||
|
def deleteTag(id: Tag.Id): F[Boolean]
|
50
modules/db/src/main/scala/gs/smolban/db/TicketDb.scala
Normal file
50
modules/db/src/main/scala/gs/smolban/db/TicketDb.scala
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
package gs.smolban.db
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import gs.smolban.model.Ticket
|
||||||
|
|
||||||
|
/** Database interface for [[Ticket]].
|
||||||
|
*/
|
||||||
|
trait TicketDb[F[_]]:
|
||||||
|
/** Create a new [[Ticket]] in the database.
|
||||||
|
*
|
||||||
|
* @param ticket
|
||||||
|
* The [[Ticket]] to store.
|
||||||
|
* @return
|
||||||
|
* The [[Ticket]] that was created, or an error.
|
||||||
|
*/
|
||||||
|
def createTicket(ticket: Ticket): EitherT[F, DbError, Ticket]
|
||||||
|
|
||||||
|
/** Read the specified [[Ticket]] from the database.
|
||||||
|
*
|
||||||
|
* @param ref
|
||||||
|
* The reference to the [[Ticket]] to retrieve.
|
||||||
|
* @return
|
||||||
|
* The value of the [[Ticket]], or `None` if no such [[Ticket]] exists.
|
||||||
|
*/
|
||||||
|
def readTicket(ref: Ticket.Reference): F[Option[Ticket]]
|
||||||
|
|
||||||
|
/** Given a reference to a [[Ticket]] and some new value for that [[Ticket]],
|
||||||
|
* update the [[Ticket]] in the database.
|
||||||
|
*
|
||||||
|
* @param ref
|
||||||
|
* The refrence to the [[Ticket]] to update.
|
||||||
|
* @param newValue
|
||||||
|
* The new values with which to overwrite the [[Ticket]].
|
||||||
|
* @return
|
||||||
|
* The modified [[Ticket]] if the operation succeeded, or `None` if the
|
||||||
|
* given reference does not exist.
|
||||||
|
*/
|
||||||
|
def updateTicket(
|
||||||
|
ref: Ticket.Reference,
|
||||||
|
newValue: Ticket
|
||||||
|
): F[Option[Ticket]]
|
||||||
|
|
||||||
|
/** Delete the specified [[Ticket]].
|
||||||
|
*
|
||||||
|
* @param ref
|
||||||
|
* The reference to the [[Ticket]] to delete.
|
||||||
|
* @return
|
||||||
|
* `true` if the operation deleted a [[Ticket]], `false` otherwise.
|
||||||
|
*/
|
||||||
|
def deleteTicket(ref: Ticket.Reference): F[Boolean]
|
|
@ -0,0 +1,90 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.MonadError
|
||||||
|
import cats.data.EitherT
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
import gs.smolban.db.DbError
|
||||||
|
import gs.smolban.db.GroupDb
|
||||||
|
import gs.smolban.db.doobie.DoobieTypes.*
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.smolban.model.Group
|
||||||
|
|
||||||
|
final class DoobieGroupDb() extends GroupDb[ConnectionIO]:
|
||||||
|
|
||||||
|
import DoobieGroupDb.Sql
|
||||||
|
import gs.smolban.db.DbConstants
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def createGroup(
|
||||||
|
group: Group
|
||||||
|
): EitherT[ConnectionIO, DbError, Group] =
|
||||||
|
EitherT(
|
||||||
|
Sql.createGroup(group).run.attemptSql.flatMap {
|
||||||
|
case Left(ex) =>
|
||||||
|
if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then
|
||||||
|
Left(
|
||||||
|
DbError.GroupAlreadyExists(group.id, group.slug)
|
||||||
|
).pure[ConnectionIO]
|
||||||
|
else MonadError[ConnectionIO, Throwable].raiseError(ex)
|
||||||
|
case Right(_) =>
|
||||||
|
Right(group).pure[ConnectionIO]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def readGroup(id: Group.Id): ConnectionIO[Option[Group]] =
|
||||||
|
Sql.readGroup(id).option
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def deleteGroup(id: Group.Id): ConnectionIO[Boolean] =
|
||||||
|
for
|
||||||
|
_ <- Sql.deleteAllTicketsForGroup(id).run
|
||||||
|
rows <- Sql.deleteGroup(id).run
|
||||||
|
yield rows > 0
|
||||||
|
|
||||||
|
object DoobieGroupDb:
|
||||||
|
|
||||||
|
private object Sql:
|
||||||
|
|
||||||
|
def createGroup(group: Group): Update0 = sql"""
|
||||||
|
INSERT INTO groups (group_id, slug, created_at)
|
||||||
|
VALUES (${group.id}, ${group.slug}, ${group.createdAt})
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def readGroup(groupId: Group.Id): Query0[Group] = sql"""
|
||||||
|
SELECT slug, created_at
|
||||||
|
FROM groups
|
||||||
|
WHERE group_id = $groupId
|
||||||
|
""".query[Group]
|
||||||
|
|
||||||
|
def deleteAllTicketsForGroup(groupId: Group.Id): Update0 = sql"""
|
||||||
|
DELETE FROM tickets
|
||||||
|
WHERE group_id = $groupId
|
||||||
|
""".update
|
||||||
|
|
||||||
|
def deleteGroup(groupId: Group.Id): Update0 = sql"""
|
||||||
|
DELETE FROM groups
|
||||||
|
WHERE group_id = $groupId
|
||||||
|
""".update
|
||||||
|
|
||||||
|
end Sql
|
||||||
|
|
||||||
|
object ErrorCodes:
|
||||||
|
|
||||||
|
val UniqueConstraintFailed: List[Int] =
|
||||||
|
List(Sqlite.UniqueConstraintFailed)
|
||||||
|
|
||||||
|
object Sqlite:
|
||||||
|
|
||||||
|
val UniqueConstraintFailed: Int = 2067
|
||||||
|
|
||||||
|
end Sqlite
|
||||||
|
|
||||||
|
end ErrorCodes
|
||||||
|
|
||||||
|
end DoobieGroupDb
|
|
@ -0,0 +1,77 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.MonadError
|
||||||
|
import cats.data.EitherT
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
import gs.smolban.db.DbError
|
||||||
|
import gs.smolban.db.TagDb
|
||||||
|
import gs.smolban.db.doobie.DoobieTypes.*
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.smolban.model.Tag
|
||||||
|
|
||||||
|
final class DoobieTagDb() extends TagDb[ConnectionIO]:
|
||||||
|
|
||||||
|
import DoobieTagDb.Sql
|
||||||
|
import gs.smolban.db.DbConstants
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def createTag(
|
||||||
|
value: Tag.Value,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): EitherT[ConnectionIO, DbError, Tag] =
|
||||||
|
EitherT(
|
||||||
|
Sql.createTag(value, createdAt).option.attemptSql.flatMap {
|
||||||
|
case Left(ex) =>
|
||||||
|
if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then
|
||||||
|
Left(
|
||||||
|
DbError.TagAlreadyExists(value)
|
||||||
|
).pure[ConnectionIO]
|
||||||
|
else MonadError[ConnectionIO, Throwable].raiseError(ex)
|
||||||
|
case Right(None) =>
|
||||||
|
Left(DbError.SilentInsertFailure("doobie-tag-db")).pure[ConnectionIO]
|
||||||
|
case Right(Some(newId)) =>
|
||||||
|
Right(Tag(newId, value, createdAt)).pure[ConnectionIO]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def readTag(id: Tag.Id): ConnectionIO[Option[Tag]] =
|
||||||
|
Sql.readTag(id).option
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def deleteTag(id: Tag.Id): ConnectionIO[Boolean] =
|
||||||
|
for rows <- Sql.deleteTag(id).run
|
||||||
|
yield rows > 0
|
||||||
|
|
||||||
|
object DoobieTagDb:
|
||||||
|
|
||||||
|
private object Sql:
|
||||||
|
|
||||||
|
def createTag(
|
||||||
|
tv: Tag.Value,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
): Query0[Tag.Id] = sql"""
|
||||||
|
INSERT INTO tags (tag_value, created_at)
|
||||||
|
VALUES ($tv, $createdAt)
|
||||||
|
RETURNING id
|
||||||
|
""".query[Tag.Id]
|
||||||
|
|
||||||
|
def readTag(tagId: Tag.Id): Query0[Tag] = sql"""
|
||||||
|
SELECT id, value, created_at
|
||||||
|
FROM tags
|
||||||
|
WHERE id = $tagId
|
||||||
|
""".query[Tag]
|
||||||
|
|
||||||
|
def deleteTag(tagId: Tag.Id): Update0 = sql"""
|
||||||
|
DELETE FROM tags
|
||||||
|
WHERE id = $tagId
|
||||||
|
""".update
|
||||||
|
|
||||||
|
end Sql
|
||||||
|
|
||||||
|
end DoobieTagDb
|
|
@ -0,0 +1,101 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import cats.data.EitherT
|
||||||
|
import cats.syntax.all.*
|
||||||
|
import doobie.*
|
||||||
|
import doobie.implicits.*
|
||||||
|
import gs.smolban.db.DbError
|
||||||
|
import gs.smolban.db.TicketDb
|
||||||
|
import gs.smolban.db.doobie.DoobieTypes.*
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.smolban.model.Group
|
||||||
|
import gs.smolban.model.Tag
|
||||||
|
import gs.smolban.model.Ticket
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
final class DoobieTicketDb() extends TicketDb[ConnectionIO]:
|
||||||
|
import DoobieTicketDb.Sql
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def createTicket(ticket: Ticket)
|
||||||
|
: EitherT[ConnectionIO, DbError, Ticket] = ???
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def readTicket(ref: Ticket.Reference): ConnectionIO[Option[Ticket]] =
|
||||||
|
Sql.readTicket(ref.number, ref.group).option.flatMap {
|
||||||
|
case None => None.pure[ConnectionIO]
|
||||||
|
case Some(ticketContents) =>
|
||||||
|
for
|
||||||
|
tags <- Sql.readTagsForTicket(ticketContents.id).stream.compile.toList
|
||||||
|
history <- Sql
|
||||||
|
.readHistoryForTicket(ticketContents.id)
|
||||||
|
.stream
|
||||||
|
.compile
|
||||||
|
.toList
|
||||||
|
yield Some(
|
||||||
|
Ticket(
|
||||||
|
number = ref.number,
|
||||||
|
group = ref.group,
|
||||||
|
createdAt = ticketContents.createdAt,
|
||||||
|
title = ticketContents.title,
|
||||||
|
description = ticketContents.description,
|
||||||
|
tags = tags,
|
||||||
|
status = ticketContents.status,
|
||||||
|
statusHistory = history
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def updateTicket(
|
||||||
|
ref: Ticket.Reference,
|
||||||
|
newValue: Ticket
|
||||||
|
): ConnectionIO[Option[Ticket]] = ???
|
||||||
|
|
||||||
|
/** @inheritdoc
|
||||||
|
*/
|
||||||
|
override def deleteTicket(ref: Ticket.Reference): ConnectionIO[Boolean] = ???
|
||||||
|
|
||||||
|
object DoobieTicketDb:
|
||||||
|
|
||||||
|
private object Sql:
|
||||||
|
|
||||||
|
case class TicketContents(
|
||||||
|
id: Long,
|
||||||
|
createdAt: CreatedAt,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
status: Ticket.Status
|
||||||
|
)
|
||||||
|
|
||||||
|
def readTicket(
|
||||||
|
ticketNumber: Ticket.Number,
|
||||||
|
groupId: Group.Id
|
||||||
|
): Query0[TicketContents] = sql"""
|
||||||
|
SELECT id, created_at, created_by, title, description, status, assignee
|
||||||
|
FROM tickets
|
||||||
|
WHERE ticket_number = $ticketNumber AND group_id = $groupId
|
||||||
|
""".query[TicketContents]
|
||||||
|
|
||||||
|
def readTagsForTicket(
|
||||||
|
ticketId: Long
|
||||||
|
): Query0[Tag] = sql"""
|
||||||
|
SELECT id, tag_value, created_at
|
||||||
|
FROM tags
|
||||||
|
WHERE ticket_id = $ticketId
|
||||||
|
""".query[Tag]
|
||||||
|
|
||||||
|
def readHistoryForTicket(
|
||||||
|
ticketId: Long
|
||||||
|
): Query0[(Ticket.Status, Instant)] = sql"""
|
||||||
|
SELECT status, set_at
|
||||||
|
FROM ticket_history
|
||||||
|
WHERE ticket_id = $ticketId
|
||||||
|
""".query[(Ticket.Status, Instant)]
|
||||||
|
|
||||||
|
end Sql
|
||||||
|
|
||||||
|
end DoobieTicketDb
|
|
@ -0,0 +1,91 @@
|
||||||
|
package gs.smolban.db.doobie
|
||||||
|
|
||||||
|
import DoobieTypes.ErrorMessages
|
||||||
|
import doobie.*
|
||||||
|
import gs.slug.v0.Slug
|
||||||
|
import gs.smolban.model.CreatedAt
|
||||||
|
import gs.smolban.model.Group
|
||||||
|
import gs.smolban.model.Tag
|
||||||
|
import gs.smolban.model.Ticket
|
||||||
|
import gs.uuid.v0.UUID
|
||||||
|
import gs.uuid.v0.UUIDFormat
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
trait DoobieTypes:
|
||||||
|
|
||||||
|
implicit val ticketNumberGet: Get[Ticket.Number] =
|
||||||
|
Get[Long].tmap(Ticket.Number(_))
|
||||||
|
|
||||||
|
implicit val ticketNumberPut: Put[Ticket.Number] =
|
||||||
|
Put[Long].tcontramap(_.toLong())
|
||||||
|
|
||||||
|
implicit val ticketStatusGet: Get[Ticket.Status] =
|
||||||
|
Get[String].temap(dbValue =>
|
||||||
|
Ticket.Status.parse(dbValue) match
|
||||||
|
case None => Left(ErrorMessages.invalidTicketStatus(dbValue))
|
||||||
|
case Some(status) => Right(status)
|
||||||
|
)
|
||||||
|
|
||||||
|
implicit val ticketStatusPut: Put[Ticket.Status] =
|
||||||
|
Put[String].tcontramap(_.name)
|
||||||
|
|
||||||
|
implicit val groupIdGet: Get[Group.Id] =
|
||||||
|
Get[Array[Byte]].tmap(bytes => Group.Id(UUID(UUIDFormat.fromBytes(bytes))))
|
||||||
|
|
||||||
|
implicit val groupIdPut: Put[Group.Id] =
|
||||||
|
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
|
||||||
|
|
||||||
|
implicit val createdAtGet: Get[CreatedAt] =
|
||||||
|
Get[Long].tmap(CreatedAt.fromMilliseconds)
|
||||||
|
|
||||||
|
implicit val createdAtPut: Put[CreatedAt] =
|
||||||
|
Put[Long].tcontramap(_.toMilliseconds())
|
||||||
|
|
||||||
|
implicit val instantGet: Get[Instant] =
|
||||||
|
Get[Long].tmap(Instant.ofEpochMilli)
|
||||||
|
|
||||||
|
implicit val instantPut: Put[Instant] =
|
||||||
|
Put[Long].tcontramap(_.toEpochMilli())
|
||||||
|
|
||||||
|
implicit val slugGet: Get[Slug] =
|
||||||
|
Get[String].temap { slug =>
|
||||||
|
Slug.validate(slug) match
|
||||||
|
case None => Left(ErrorMessages.invalidSlug(slug))
|
||||||
|
case Some(s) => Right(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
implicit val slugPut: Put[Slug] =
|
||||||
|
Put[String].tcontramap(_.str())
|
||||||
|
|
||||||
|
implicit val tagIdGet: Get[Tag.Id] =
|
||||||
|
Get[Long].tmap(Tag.Id(_))
|
||||||
|
|
||||||
|
implicit val tagIdPut: Put[Tag.Id] =
|
||||||
|
Put[Long].tcontramap(_.toLong())
|
||||||
|
|
||||||
|
implicit val tagValueGet: Get[Tag.Value] =
|
||||||
|
Get[String].temap { value =>
|
||||||
|
Tag.Value.validate(value) match
|
||||||
|
case None => Left(ErrorMessages.invalidTagValue(value))
|
||||||
|
case Some(s) => Right(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
implicit val tagValuePut: Put[Tag.Value] =
|
||||||
|
Put[String].tcontramap(_.toString())
|
||||||
|
|
||||||
|
object DoobieTypes extends DoobieTypes:
|
||||||
|
|
||||||
|
object ErrorMessages:
|
||||||
|
|
||||||
|
def invalidTagValue(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid tag value."
|
||||||
|
|
||||||
|
def invalidTicketStatus(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid ticket status."
|
||||||
|
|
||||||
|
def invalidSlug(candidate: String): String =
|
||||||
|
s"'$candidate' is not a valid Group slug."
|
||||||
|
|
||||||
|
end ErrorMessages
|
||||||
|
|
||||||
|
end DoobieTypes
|
|
@ -19,6 +19,15 @@ object CreatedAt:
|
||||||
*/
|
*/
|
||||||
def apply(timestamp: Instant): CreatedAt = timestamp
|
def apply(timestamp: Instant): CreatedAt = timestamp
|
||||||
|
|
||||||
|
/** Instantiate a new [[CreatedAt]].
|
||||||
|
*
|
||||||
|
* @param millis
|
||||||
|
* The epoch milliseconds describing the instant of this timestamp.
|
||||||
|
* @return
|
||||||
|
* The new instance.
|
||||||
|
*/
|
||||||
|
def fromMilliseconds(millis: Long): CreatedAt = Instant.ofEpochMilli(millis)
|
||||||
|
|
||||||
given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived
|
given CanEqual[CreatedAt, CreatedAt] = CanEqual.derived
|
||||||
|
|
||||||
given Show[CreatedAt] = _.toInstant().toString()
|
given Show[CreatedAt] = _.toInstant().toString()
|
||||||
|
@ -31,4 +40,11 @@ object CreatedAt:
|
||||||
*/
|
*/
|
||||||
def toInstant(): Instant = createdAt
|
def toInstant(): Instant = createdAt
|
||||||
|
|
||||||
|
/** Express this instant as epoch milliseconds.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The epoch milliseconds.
|
||||||
|
*/
|
||||||
|
def toMilliseconds(): Long = createdAt.toEpochMilli()
|
||||||
|
|
||||||
end CreatedAt
|
end CreatedAt
|
||||||
|
|
|
@ -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
|
|
|
@ -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:
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
package gs.smolban.model
|
||||||
|
|
||||||
|
/** The parent trait for all errors in Smolban.
|
||||||
|
*/
|
||||||
|
trait SmolbanError
|
|
@ -2,20 +2,62 @@ package gs.smolban.model
|
||||||
|
|
||||||
import cats.Show
|
import cats.Show
|
||||||
|
|
||||||
/** 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
|
case class Tag(
|
||||||
|
id: Tag.Id,
|
||||||
|
value: Tag.Value,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
)
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
|
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
|
end Tag
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
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.
|
||||||
*
|
*
|
||||||
* @param id
|
* @param number
|
||||||
* Unique identifier _within_ the [[Group]].
|
* Unique number _within_ the [[Group]] Typically ascending integers.
|
||||||
* @param group
|
* @param group
|
||||||
* 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,54 +21,63 @@ 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,
|
number: Ticket.Number,
|
||||||
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(number, group)
|
||||||
|
|
||||||
object Ticket:
|
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
|
/** Unique identifier - relative to some [[Group]] - for a [[Ticket]]. This is
|
||||||
* an opaque type for a Long. In general, [[Ticket]] identifiers are
|
* an opaque type for a Long. In general, [[Ticket]] identifiers are
|
||||||
* sequences within a group.
|
* sequences within a group.
|
||||||
*/
|
*/
|
||||||
opaque type Id = Long
|
opaque type Number = Long
|
||||||
|
|
||||||
object Id:
|
object Number:
|
||||||
|
|
||||||
/** Instantiate a new [[Ticket.Id]].
|
/** Instantiate a new [[Ticket.Number]].
|
||||||
*
|
*
|
||||||
* @param id
|
* @param number
|
||||||
* The underlying Long.
|
* The underlying Long.
|
||||||
* @return
|
* @return
|
||||||
* The new [[Ticket.Id]] instance.
|
* The new [[Ticket.Number]] instance.
|
||||||
*/
|
*/
|
||||||
def apply(id: Long): Id = id
|
def apply(number: Long): Number = number
|
||||||
|
|
||||||
given CanEqual[Id, Id] = CanEqual.derived
|
given CanEqual[Number, Number] = CanEqual.derived
|
||||||
|
|
||||||
given Show[Id] = _.toLong().toString()
|
given Show[Number] = _.toLong().toString()
|
||||||
|
|
||||||
extension (id: Id)
|
extension (number: Number)
|
||||||
/** Unwrap this Ticket ID.
|
/** Unwrap this Ticket ID.
|
||||||
*
|
*
|
||||||
* @return
|
* @return
|
||||||
* The underlying Long value.
|
* The underlying Long value.
|
||||||
*/
|
*/
|
||||||
def toLong(): Long = id
|
def toLong(): Long = number
|
||||||
|
|
||||||
end Id
|
end Number
|
||||||
|
|
||||||
/** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban
|
/** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban
|
||||||
* does not yet support custom status/workflow.
|
* does not yet support custom status/workflow.
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -1 +1 @@
|
||||||
sbt.version=1.10.0
|
sbt.version=1.10.1
|
||||||
|
|
Loading…
Add table
Reference in a new issue