diff --git a/build.sbt b/build.sbt index 32ed4d0b..88b9e48b 100644 --- a/build.sbt +++ b/build.sbt @@ -183,6 +183,8 @@ val openapiScalaSettings = Seq( ) case "glob" => field => field.copy(typeDef = TypeDef("Glob", Imports("docspell.common.Glob"))) + case "customfieldtype" => + field => field.copy(typeDef = TypeDef("CustomFieldType", Imports("docspell.common.CustomFieldType"))) })) ) diff --git a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala index be76d45b..81328296 100644 --- a/modules/backend/src/main/scala/docspell/backend/BackendApp.scala +++ b/modules/backend/src/main/scala/docspell/backend/BackendApp.scala @@ -36,6 +36,7 @@ trait BackendApp[F[_]] { def joex: OJoex[F] def userTask: OUserTask[F] def folder: OFolder[F] + def customFields: OCustomFields[F] } object BackendApp { @@ -66,27 +67,29 @@ object BackendApp { fulltextImpl <- OFulltext(itemSearchImpl, ftsClient, store, queue, joexImpl) javaEmil = JavaMailEmil(blocker, Settings.defaultSettings.copy(debug = cfg.mailDebug)) - mailImpl <- OMail(store, javaEmil) - userTaskImpl <- OUserTask(utStore, queue, joexImpl) - folderImpl <- OFolder(store) + mailImpl <- OMail(store, javaEmil) + userTaskImpl <- OUserTask(utStore, queue, joexImpl) + folderImpl <- OFolder(store) + customFieldsImpl <- OCustomFields(store) } yield new BackendApp[F] { - val login: Login[F] = loginImpl - val signup: OSignup[F] = signupImpl - val collective: OCollective[F] = collImpl - val source = sourceImpl - val tag = tagImpl - val equipment = equipImpl - val organization = orgImpl - val upload = uploadImpl - val node = nodeImpl - val job = jobImpl - val item = itemImpl - val itemSearch = itemSearchImpl - val fulltext = fulltextImpl - val mail = mailImpl - val joex = joexImpl - val userTask = userTaskImpl - val folder = folderImpl + val login = loginImpl + val signup = signupImpl + val collective = collImpl + val source = sourceImpl + val tag = tagImpl + val equipment = equipImpl + val organization = orgImpl + val upload = uploadImpl + val node = nodeImpl + val job = jobImpl + val item = itemImpl + val itemSearch = itemSearchImpl + val fulltext = fulltextImpl + val mail = mailImpl + val joex = joexImpl + val userTask = userTaskImpl + val folder = folderImpl + val customFields = customFieldsImpl } def apply[F[_]: ConcurrentEffect: ContextShift]( diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala new file mode 100644 index 00000000..e8058b17 --- /dev/null +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -0,0 +1,25 @@ +package docspell.backend.ops + +import cats.effect.{Effect, Resource} + +import docspell.common._ +import docspell.store.Store +import docspell.store.records.RCustomField + +trait OCustomFields[F[_]] { + + def findAll(coll: Ident): F[Vector[RCustomField]] + +} + +object OCustomFields { + + def apply[F[_]: Effect]( + store: Store[F] + ): Resource[F, OCustomFields[F]] = + Resource.pure[F, OCustomFields[F]](new OCustomFields[F] { + + def findAll(coll: Ident): F[Vector[RCustomField]] = + store.transact(RCustomField.findAll(coll)) + }) +} diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala new file mode 100644 index 00000000..021bbe28 --- /dev/null +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -0,0 +1,51 @@ +package docspell.common + +import io.circe._ + +sealed trait CustomFieldType { self: Product => + + final def name: String = + self.productPrefix.toLowerCase() + +} + +object CustomFieldType { + + case object Text extends CustomFieldType + + case object Numeric extends CustomFieldType + + case object Date extends CustomFieldType + + case object Bool extends CustomFieldType + + case object Money extends CustomFieldType + + def text: CustomFieldType = Text + def numeric: CustomFieldType = Numeric + def date: CustomFieldType = Date + def bool: CustomFieldType = Bool + def money: CustomFieldType = Money + + val all: List[CustomFieldType] = List(Text, Numeric, Date, Bool, Money) + + def fromString(str: String): Either[String, CustomFieldType] = + str.toLowerCase match { + case "text" => Right(text) + case "numeric" => Right(numeric) + case "date" => Right(date) + case "bool" => Right(bool) + case "money" => Right(money) + case _ => Left(s"Unknown custom field: $str") + } + + def unsafe(str: String): CustomFieldType = + fromString(str).fold(sys.error, identity) + + + implicit val jsonDecoder: Decoder[CustomFieldType] = + Decoder.decodeString.emap(fromString) + + implicit val jsonEncoder: Encoder[CustomFieldType] = + Encoder.encodeString.contramap(_.name) +} diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index c41a6877..8acefbec 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -3202,6 +3202,23 @@ paths: schema: $ref: "#/components/schemas/BasicResult" + /sec/customfields: + get: + tags: [ Custom Fields ] + summary: Get all defined custom fields. + description: | + Get all custom fields defined for the current collective. + security: + - authTokenHeader: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/CustomFieldList" + + components: schemas: ItemsAndRefs: @@ -3282,6 +3299,38 @@ components: format: date-time + CustomFieldList: + description: | + A list of known custom fields. + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/CustomField" + + CustomField: + description: | + A custom field definition. + required: + - id + - name + - ftype + - created + properties: + id: + type: string + format: ident + name: + type: string + ftype: + type: string + format: customfieldtype + created: + type: integer + format: date-time + JobPriority: description: | Transfer the priority of a job. @@ -4372,6 +4421,7 @@ components: type: array items: $ref: "#/components/schemas/SourceAndTags" + Source: description: | Data about a Source. A source defines the endpoint where diff --git a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala index 9dbba2b0..9c23e589 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/RestServer.scala @@ -85,7 +85,8 @@ object RestServer { "usertask/scanmailbox" -> ScanMailboxRoutes(restApp.backend, token), "calevent/check" -> CalEventCheckRoutes(), "fts" -> FullTextIndexRoutes.secured(cfg, restApp.backend, token), - "folder" -> FolderRoutes(restApp.backend, token) + "folder" -> FolderRoutes(restApp.backend, token), + "customfield" -> CustomFieldRoutes(restApp.backend, token) ) def openRoutes[F[_]: Effect](cfg: Config, restApp: RestApp[F]): HttpRoutes[F] = diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala new file mode 100644 index 00000000..14313262 --- /dev/null +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/CustomFieldRoutes.scala @@ -0,0 +1,35 @@ +package docspell.restserver.routes + +import cats.effect._ +import cats.implicits._ + +import docspell.backend.BackendApp +import docspell.backend.auth.AuthToken +import docspell.restapi.model._ +import docspell.restserver.http4s._ + +import org.http4s.HttpRoutes +//import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.circe.CirceEntityEncoder._ +import org.http4s.dsl.Http4sDsl +import docspell.store.records.RCustomField + +object CustomFieldRoutes { + + def apply[F[_]: Effect](backend: BackendApp[F], user: AuthToken): HttpRoutes[F] = { + val dsl = new Http4sDsl[F] with ResponseGenerator[F] {} + import dsl._ + + HttpRoutes.of { + case GET -> Root => + for { + fs <- backend.customFields.findAll(user.account.collective) + res <- Ok(CustomFieldList(fs.map(convertField).toList)) + } yield res + } + } + + + private def convertField(f: RCustomField): CustomField = + CustomField(f.id, f.name, f.ftype, f.created) +} diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql new file mode 100644 index 00000000..2614e9e5 --- /dev/null +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -0,0 +1,20 @@ +CREATE TABLE "custom_field" ( + "id" varchar(254) not null primary key, + "name" varchar(254) not null, + "cid" varchar(254) not null, + "ftype" varchar(100) not null, + "created" timestamp not null, + foreign key ("cid") references "collective"("cid"), + unique ("cid", "name") +); + +CREATE TABLE "custom_field_value" ( + "id" varchar(254) not null primary key, + "item_id" varchar(254) not null, + "field" varchar(254) not null, + "value_text" varchar(300), + "value_numeric" numeric, + foreign key ("item_id") references "item"("itemid"), + foreign key ("field") references "custom_field"("id"), + unique ("item_id", "field") +) diff --git a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala index 0e2ed027..cbe3ab0f 100644 --- a/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala +++ b/modules/store/src/main/scala/docspell/store/impl/DoobieMeta.scala @@ -94,6 +94,9 @@ trait DoobieMeta extends EmilDoobieMeta { implicit val metaGlob: Meta[Glob] = Meta[String].timap(Glob.apply)(_.asString) + + implicit val metaCustomFieldType: Meta[CustomFieldType] = + Meta[String].timap(CustomFieldType.unsafe)(_.name) } object DoobieMeta extends DoobieMeta { diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala new file mode 100644 index 00000000..5b4492e2 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -0,0 +1,44 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RCustomField( + id: Ident, + name: String, + cid: Ident, + ftype: CustomFieldType, + created: Timestamp +) + +object RCustomField { + + val table = fr"custom_field" + + object Columns { + + val id = Column("id") + val name = Column("name") + val cid = Column("cid") + val ftype = Column("ftype") + val created = Column("created") + + val all = List(id, name, cid, ftype, created) + } + + def insert(value: RCustomField): ConnectionIO[Int] = { + val sql = insertRow( + table, + Columns.all, + fr"${value.id},${value.name},${value.cid},${value.ftype},${value.created}" + ) + sql.update.run + } + + def findAll(coll: Ident): ConnectionIO[Vector[RCustomField]] = + selectSimple(Columns.all, table, Columns.cid.is(coll)).query[RCustomField].to[Vector] +} diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala new file mode 100644 index 00000000..1bc90d66 --- /dev/null +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -0,0 +1,42 @@ +package docspell.store.records + +import docspell.common._ +import docspell.store.impl.Column +import docspell.store.impl.Implicits._ + +import doobie._ +import doobie.implicits._ + +case class RCustomFieldValue( + id: Ident, + itemId: Ident, + field: Ident, + valueText: Option[String], + valueNumeric: Option[BigDecimal] +) + +object RCustomFieldValue { + + val table = fr"custom_field_value" + + object Columns { + + val id = Column("id") + val itemId = Column("item_id") + val field = Column("field") + val valueText = Column("value_text") + val valueNumeric = Column("value_numeric") + + val all = List(id, itemId, field, valueText, valueNumeric) + } + + def insert(value: RCustomFieldValue): ConnectionIO[Int] = { + val sql = insertRow( + table, + Columns.all, + fr"${value.id},${value.itemId},${value.field},${value.valueText},${value.valueNumeric}" + ) + sql.update.run + } + +}