From 8eb59dd7c7da6d40c763d96626bc39ee33f9d5bb Mon Sep 17 00:00:00 2001 From: Simon Bruder Date: Thu, 25 Jul 2024 17:49:19 +0200 Subject: [PATCH] Move database operations to separate module This can be seen as partially reverting e83bc8316ef7a9fcb3d5852bf3c5df1794098b75, which moved everything to the place where it was used. --- ...1efedec34bf08f99dda0397df4ca873859be6.json | 28 +++ ...b5e27a5a90908d65b7a6f6d6587f8a3e2a10.json} | 4 +- ...5a55622b6d4f035d0276d1644f6500491989.json} | 4 +- ...271e351ca34273dfdb21a7499f588715a91ee.json | 74 ------- ...f8f3c3841a4424da04aee0136c7e4f8df79e7.json | 22 -- ...939d83454bb504082cb2efa723b80fde7b159.json | 32 +++ ...f78c11283a29411233597bd27a52a934a595.json} | 4 +- ...429177ea5af957398553046590aedd0f4040b.json | 74 +++++++ ...d8e79849d10798c8d55fa01f99d5245398f6a.json | 22 ++ ...ed503e6bf446c2562cbddae918c971403240.json} | 4 +- src/database/item_classes/add.rs | 38 ++++ src/database/item_classes/datalist.rs | 26 +++ src/database/item_classes/delete.rs | 18 ++ src/database/item_classes/edit.rs | 44 ++++ src/database/item_classes/list.rs | 36 ++++ src/database/item_classes/mod.rs | 40 ++++ src/database/item_classes/show.rs | 78 ++++++++ src/database/item_events.rs | 96 +++++++++ src/database/item_states.rs | 73 +++++++ src/database/items/add.rs | 67 +++++++ src/database/items/datalist.rs | 31 +++ src/database/items/delete.rs | 18 ++ src/database/items/edit.rs | 64 ++++++ src/database/items/label.rs | 23 +++ src/database/items/list.rs | 95 +++++++++ src/database/items/mod.rs | 166 ++++++++++++++++ src/database/items/show.rs | 72 +++++++ src/database/mod.rs | 14 ++ src/database/search.rs | 41 ++++ src/frontend/item/add.rs | 131 ++++-------- src/frontend/item/delete.rs | 9 +- src/frontend/item/edit.rs | 82 ++------ src/frontend/item/event.rs | 40 +--- src/frontend/item/list.rs | 83 +------- src/frontend/item/show.rs | 177 +++-------------- src/frontend/item_class/add.rs | 49 ++--- src/frontend/item_class/delete.rs | 9 +- src/frontend/item_class/edit.rs | 48 ++--- src/frontend/item_class/list.rs | 39 +--- src/frontend/item_class/show.rs | 80 ++------ src/frontend/jump.rs | 56 ++---- src/frontend/labels.rs | 42 ++-- src/frontend/mod.rs | 2 +- src/frontend/templates/datalist.rs | 50 +---- src/frontend/templates/helpers.rs | 188 +----------------- src/frontend/templates/mod.rs | 1 + src/frontend/templates/render.rs | 70 +++++++ src/label/mod.rs | 21 +- src/lib.rs | 1 + src/main.rs | 8 + 50 files changed, 1520 insertions(+), 974 deletions(-) create mode 100644 .sqlx/query-05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6.json rename .sqlx/{query-857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91.json => query-087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10.json} (64%) rename .sqlx/{query-460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125.json => query-265104a03d3ce2a41b58b1fa5cac5a55622b6d4f035d0276d1644f6500491989.json} (73%) delete mode 100644 .sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json delete mode 100644 .sqlx/query-719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7.json create mode 100644 .sqlx/query-84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159.json rename .sqlx/{query-c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616.json => query-8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595.json} (89%) create mode 100644 .sqlx/query-cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b.json create mode 100644 .sqlx/query-f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a.json rename .sqlx/{query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json => query-fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240.json} (83%) create mode 100644 src/database/item_classes/add.rs create mode 100644 src/database/item_classes/datalist.rs create mode 100644 src/database/item_classes/delete.rs create mode 100644 src/database/item_classes/edit.rs create mode 100644 src/database/item_classes/list.rs create mode 100644 src/database/item_classes/mod.rs create mode 100644 src/database/item_classes/show.rs create mode 100644 src/database/item_events.rs create mode 100644 src/database/item_states.rs create mode 100644 src/database/items/add.rs create mode 100644 src/database/items/datalist.rs create mode 100644 src/database/items/delete.rs create mode 100644 src/database/items/edit.rs create mode 100644 src/database/items/label.rs create mode 100644 src/database/items/list.rs create mode 100644 src/database/items/mod.rs create mode 100644 src/database/items/show.rs create mode 100644 src/database/mod.rs create mode 100644 src/database/search.rs create mode 100644 src/frontend/templates/render.rs diff --git a/.sqlx/query-05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6.json b/.sqlx/query-05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6.json new file mode 100644 index 0000000..ed17e69 --- /dev/null +++ b/.sqlx/query-05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n items.name,\n item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n WHERE items.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "class_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "05d5b6e3618b5bb676d0a262a531efedec34bf08f99dda0397df4ca873859be6" +} diff --git a/.sqlx/query-857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91.json b/.sqlx/query-087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10.json similarity index 64% rename from .sqlx/query-857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91.json rename to .sqlx/query-087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10.json index 23d3be8..22161ec 100644 --- a/.sqlx/query-857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91.json +++ b/.sqlx/query-087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO items (name, parent, class, original_packaging, description)\n SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])\n RETURNING id", + "query": "INSERT INTO items (name, parent, class, original_packaging, description)\n SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[])\n RETURNING id", "describe": { "columns": [ { @@ -22,5 +22,5 @@ false ] }, - "hash": "857bbaea0fa73c37679d927aa627ee841c5b89c86af12f32493ba05c0f0ead91" + "hash": "087164492bb5f51006e5091f9382b5e27a5a90908d65b7a6f6d6587f8a3e2a10" } diff --git a/.sqlx/query-460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125.json b/.sqlx/query-265104a03d3ce2a41b58b1fa5cac5a55622b6d4f035d0276d1644f6500491989.json similarity index 73% rename from .sqlx/query-460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125.json rename to .sqlx/query-265104a03d3ce2a41b58b1fa5cac5a55622b6d4f035d0276d1644f6500491989.json index 25af06d..b4dbb06 100644 --- a/.sqlx/query-460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125.json +++ b/.sqlx/query-265104a03d3ce2a41b58b1fa5cac5a55622b6d4f035d0276d1644f6500491989.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id AS \"id?\", to_char(short_id, '000000') AS \"short_id?\"\n FROM items\n WHERE id = ANY ($1)", + "query": "SELECT id AS \"id?\", to_char(short_id, '000000') AS \"short_id?\"\n FROM items\n WHERE id = ANY ($1)", "describe": { "columns": [ { @@ -24,5 +24,5 @@ null ] }, - "hash": "460f15b3c5ec2774c237fc581166bf44ecdb0b6145f8abba155478643a474125" + "hash": "265104a03d3ce2a41b58b1fa5cac5a55622b6d4f035d0276d1644f6500491989" } diff --git a/.sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json b/.sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json deleted file mode 100644 index e93c7e3..0000000 --- a/.sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION\n\n SELECT\n items.id,\n cte.parents || items.parent,\n cte.parent_names || parent.name,\n cte.parent_class_names || parent_class.name\n FROM cte\n JOIN items\n ON items.parent = cte.id\n JOIN items AS \"parent\"\n ON parent.id = cte.id\n JOIN item_classes AS \"parent_class\"\n ON parent.class = parent_class.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec>\",\n cte.parent_class_names AS \"parent_class_names!\",\n item_states.state AS \"state!: ItemState\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n ORDER BY items.created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "class", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "class_name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "parents!", - "type_info": "UuidArray" - }, - { - "ordinal": 5, - "name": "parent_names!: Vec>", - "type_info": "VarcharArray" - }, - { - "ordinal": 6, - "name": "parent_class_names!", - "type_info": "VarcharArray" - }, - { - "ordinal": 7, - "name": "state!: ItemState", - "type_info": { - "Custom": { - "name": "item_state", - "kind": { - "Enum": [ - "borrowed", - "inactive", - "loaned", - "owned" - ] - } - } - } - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - true, - false, - false, - null, - null, - null, - true - ] - }, - "hash": "3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee" -} diff --git a/.sqlx/query-719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7.json b/.sqlx/query-719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7.json deleted file mode 100644 index ea7e0a5..0000000 --- a/.sqlx/query-719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT type as \"type!\"\n FROM (SELECT id, 'item' AS \"type\" FROM items\n UNION ALL\n SELECT id, 'item_class' AS \"type\" FROM item_classes) id_mapping\n WHERE id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "type!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "719aff68ead7ada499158fec5e9f8f3c3841a4424da04aee0136c7e4f8df79e7" -} diff --git a/.sqlx/query-84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159.json b/.sqlx/query-84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159.json new file mode 100644 index 0000000..e1fc556 --- /dev/null +++ b/.sqlx/query-84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT items.id, items.name, item_classes.name AS \"class_name\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "class_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "84db69654e377998324906a80bb939d83454bb504082cb2efa723b80fde7b159" +} diff --git a/.sqlx/query-c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616.json b/.sqlx/query-8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595.json similarity index 89% rename from .sqlx/query-c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616.json rename to .sqlx/query-8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595.json index 1745408..0812c8d 100644 --- a/.sqlx/query-c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616.json +++ b/.sqlx/query-8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT class.id, class.name, class.parent, parent.name AS \"parent_name?\"\n FROM item_classes AS \"class\"\n LEFT JOIN item_classes AS \"parent\"\n ON class.parent = parent.id\n ORDER BY class.created_at\n ", + "query": "SELECT class.id, class.name, class.parent, parent.name AS \"parent_name?\"\n FROM item_classes AS \"class\"\n LEFT JOIN item_classes AS \"parent\"\n ON class.parent = parent.id\n ORDER BY class.created_at\n ", "describe": { "columns": [ { @@ -34,5 +34,5 @@ false ] }, - "hash": "c7e35dee5f56da8da3c083e191f396b95917b15768ebe0250ce7840391036616" + "hash": "8d9766469e77b291eceffc2ba17ff78c11283a29411233597bd27a52a934a595" } diff --git a/.sqlx/query-cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b.json b/.sqlx/query-cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b.json new file mode 100644 index 0000000..76995a1 --- /dev/null +++ b/.sqlx/query-cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE cte AS (\n SELECT\n id,\n ARRAY[]::UUID[] AS parents,\n ARRAY[]::VARCHAR[] AS parent_names,\n ARRAY[]::VARCHAR[] AS parent_class_names\n FROM items\n WHERE parent IS NULL\n\n UNION ALL\n\n SELECT\n items.id,\n cte.parents || items.parent,\n cte.parent_names || parent.name,\n cte.parent_class_names || parent_class.name\n FROM cte\n JOIN items\n ON items.parent = cte.id\n JOIN items AS \"parent\"\n ON parent.id = cte.id\n JOIN item_classes AS \"parent_class\"\n ON parent.class = parent_class.id\n )\n SELECT\n cte.id AS \"id!\",\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n cte.parents AS \"parents!\",\n cte.parent_names AS \"parent_names!: Vec>\",\n cte.parent_class_names AS \"parent_class_names!\",\n item_states.state AS \"state!: ItemState\"\n FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n ORDER BY items.created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "class", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "class_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "parents!", + "type_info": "UuidArray" + }, + { + "ordinal": 5, + "name": "parent_names!: Vec>", + "type_info": "VarcharArray" + }, + { + "ordinal": 6, + "name": "parent_class_names!", + "type_info": "VarcharArray" + }, + { + "ordinal": 7, + "name": "state!: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + true, + false, + false, + null, + null, + null, + true + ] + }, + "hash": "cdb1777fdc044c9b34e82dde93e429177ea5af957398553046590aedd0f4040b" +} diff --git a/.sqlx/query-f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a.json b/.sqlx/query-f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a.json new file mode 100644 index 0000000..40ba38f --- /dev/null +++ b/.sqlx/query-f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT type as \"type!\"\n FROM (SELECT id, 'item' AS \"type\" FROM items\n UNION ALL\n SELECT id, 'item_class' AS \"type\" FROM item_classes) id_mapping\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "type!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f16457df9365535d153723fc1ffd8e79849d10798c8d55fa01f99d5245398f6a" +} diff --git a/.sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json b/.sqlx/query-fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240.json similarity index 83% rename from .sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json rename to .sqlx/query-fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240.json index 490fb4b..e9163a2 100644 --- a/.sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json +++ b/.sqlx/query-fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1", + "query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1", "describe": { "columns": [ { @@ -68,5 +68,5 @@ false ] }, - "hash": "1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c" + "hash": "fecd395ba131e0b0767b8b4799eded503e6bf446c2562cbddae918c971403240" } diff --git a/src/database/item_classes/add.rs b/src/database/item_classes/add.rs new file mode 100644 index 0000000..6083101 --- /dev/null +++ b/src/database/item_classes/add.rs @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::Deserialize; +use sqlx::query_scalar; +use uuid::Uuid; + +use super::ItemClassRepository; + +#[derive(Deserialize)] +pub struct ItemClassAddForm { + pub name: String, + pub parent: Option, + pub description: String, +} + +#[derive(Deserialize)] +pub struct ItemClassAddFormPrefilled { + pub name: Option, + pub parent: Option, + pub description: Option, +} + +impl ItemClassRepository { + pub async fn add(&self, data: ItemClassAddForm) -> sqlx::Result { + query_scalar!( + "INSERT INTO item_classes (name, parent, description) + VALUES ($1, $2, $3) + RETURNING id", + data.name, + data.parent, + data.description + ) + .fetch_one(&self.pool) + .await + } +} diff --git a/src/database/item_classes/datalist.rs b/src/database/item_classes/datalist.rs new file mode 100644 index 0000000..14943ac --- /dev/null +++ b/src/database/item_classes/datalist.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; + +use super::ItemClassRepository; +use crate::frontend::templates::datalist::{Datalist, DatalistOption}; + +impl ItemClassRepository { + pub async fn datalist(&self) -> sqlx::Result { + Ok(Datalist { + name: "item-classes".to_string(), + link_prefix: Some("/item-class/".to_string()), + options: query!("SELECT id, name FROM item_classes") + .fetch_all(&self.pool) + .await? + .into_iter() + .map(|row| DatalistOption { + value: row.id.to_string(), + text: Box::new(row.name), + }) + .collect(), + }) + } +} diff --git a/src/database/item_classes/delete.rs b/src/database/item_classes/delete.rs new file mode 100644 index 0000000..a88126d --- /dev/null +++ b/src/database/item_classes/delete.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; +use uuid::Uuid; + +use super::ItemClassRepository; + +impl ItemClassRepository { + pub async fn delete(&self, id: Uuid) -> sqlx::Result<()> { + query!("DELETE FROM item_classes WHERE id = $1", id) + .execute(&self.pool) + .await?; + + Ok(()) + } +} diff --git a/src/database/item_classes/edit.rs b/src/database/item_classes/edit.rs new file mode 100644 index 0000000..c580647 --- /dev/null +++ b/src/database/item_classes/edit.rs @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::Deserialize; +use sqlx::{query, query_as}; +use uuid::Uuid; + +use super::ItemClassRepository; + +#[derive(Deserialize)] +pub struct ItemClassEditForm { + pub name: String, + pub parent: Option, + pub description: String, +} + +impl ItemClassRepository { + pub async fn edit_form(&self, id: Uuid) -> sqlx::Result { + query_as!( + ItemClassEditForm, + "SELECT name, parent, description FROM item_classes WHERE id = $1", + id + ) + .fetch_one(&self.pool) + .await + } + + pub async fn edit(&self, id: Uuid, data: &ItemClassEditForm) -> sqlx::Result<()> { + query!( + "UPDATE item_classes + SET name = $2, parent = $3, description = $4 + WHERE id = $1", + id, + data.name, + data.parent, + data.description + ) + .execute(&self.pool) + .await?; + + Ok(()) + } +} diff --git a/src/database/item_classes/list.rs b/src/database/item_classes/list.rs new file mode 100644 index 0000000..57d2bff --- /dev/null +++ b/src/database/item_classes/list.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; +use uuid::Uuid; + +use super::{ItemClassPreview, ItemClassRepository}; + +pub struct ItemClassListEntry { + pub id: Uuid, + pub name: String, + pub parent: Option, +} + +impl ItemClassRepository { + pub async fn list(&self) -> sqlx::Result> { + query!( + r#"SELECT class.id, class.name, class.parent, parent.name AS "parent_name?" + FROM item_classes AS "class" + LEFT JOIN item_classes AS "parent" + ON class.parent = parent.id + ORDER BY class.created_at + "# + ) + .map(|row| ItemClassListEntry { + id: row.id, + name: row.name, + parent: row + .parent + .map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())), + }) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/item_classes/mod.rs b/src/database/item_classes/mod.rs new file mode 100644 index 0000000..4725eba --- /dev/null +++ b/src/database/item_classes/mod.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod add; +mod datalist; +mod delete; +mod edit; +mod list; +mod show; + +use sqlx::PgPool; +use uuid::Uuid; + +pub use add::{ItemClassAddForm, ItemClassAddFormPrefilled}; +pub use edit::ItemClassEditForm; + +#[derive(Clone)] +pub struct ItemClassRepository { + pool: PgPool, +} + +impl ItemClassRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +// Common + +pub struct ItemClassPreview { + pub id: Uuid, + pub name: String, +} + +impl ItemClassPreview { + pub fn new(id: Uuid, name: String) -> Self { + Self { id, name } + } +} diff --git a/src/database/item_classes/show.rs b/src/database/item_classes/show.rs new file mode 100644 index 0000000..b9c377d --- /dev/null +++ b/src/database/item_classes/show.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::{query, query_as}; +use uuid::Uuid; + +use super::{ItemClassPreview, ItemClassRepository}; +use crate::database::item_states::ItemState; +use crate::database::items::ItemPreview; + +pub struct ItemClassDetails { + pub id: Uuid, + pub name: String, + pub description: String, + pub parent: Option, +} + +impl ItemClassRepository { + pub async fn details(&self, id: Uuid) -> sqlx::Result { + query!( + r#"SELECT + class.id, + class.name, + class.description, + class.parent, + parent.name AS "parent_name?" + FROM item_classes AS "class" + LEFT JOIN item_classes AS "parent" + ON class.parent = parent.id + WHERE class.id = $1"#, + id + ) + .map(|row| ItemClassDetails { + id: row.id, + name: row.name, + description: row.description, + parent: row + .parent + .map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())), + }) + .fetch_one(&self.pool) + .await + } + + pub async fn children(&self, id: Uuid) -> sqlx::Result> { + query_as!( + ItemClassPreview, + "SELECT id, name FROM item_classes WHERE parent = $1", + id + ) + .fetch_all(&self.pool) + .await + } + + pub async fn items(&self, id: Uuid) -> sqlx::Result> { + query!( + r#"SELECT + items.id, + items.name, + item_classes.name AS "class_name", + item_states.state AS "state!: ItemState" + FROM items + JOIN item_classes + ON items.class = item_classes.id + JOIN item_states + ON items.id = item_states.item + WHERE items.class = $1"#, + id + ) + .map(|row| { + ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name) + .with_state(row.state) + }) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/item_events.rs b/src/database/item_events.rs new file mode 100644 index 0000000..f09963f --- /dev/null +++ b/src/database/item_events.rs @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_as, query_scalar, PgPool}; +use time::Date; +use uuid::Uuid; + +#[derive(Clone)] +pub struct ItemEventRepository { + pool: PgPool, +} + +impl ItemEventRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +// Common + +#[derive(Debug, Deserialize, Serialize, sqlx::Type)] +#[sqlx(rename_all = "snake_case", type_name = "item_event")] +#[serde(rename_all = "snake_case")] +pub enum ItemEvent { + Acquire, + Borrow, + Buy, + Dispose, + Gift, + Loan, + Lose, + RecieveGift, + ReturnBorrowed, + ReturnLoaned, + Sell, + Use, +} + +// Add + +#[derive(Deserialize)] +pub struct EventAddForm { + pub date: Date, + pub event: ItemEvent, + pub description: String, +} + +impl ItemEventRepository { + pub async fn add(&self, item: Uuid, data: EventAddForm) -> sqlx::Result<()> { + query!( + "INSERT INTO item_events (item, date, event, description) + VALUES ($1, $2, $3, $4)", + item, + data.date, + data.event as ItemEvent, + data.description + ) + .execute(&self.pool) + .await?; + + Ok(()) + } +} + +// Delete + +impl ItemEventRepository { + pub async fn delete(&self, id: i32) -> sqlx::Result { + query_scalar!("DELETE FROM item_events WHERE id = $1 RETURNING item", id) + .fetch_one(&self.pool) + .await + } +} + +// Get + +pub struct ItemEventDetails { + pub id: i32, + pub date: Date, + pub event: ItemEvent, + pub description: String, +} + +impl ItemEventRepository { + pub async fn for_item(&self, item: Uuid) -> sqlx::Result> { + query_as!( + ItemEventDetails, + r#"SELECT id, date, event AS "event: ItemEvent", description FROM item_events WHERE item = $1"#, + item + ) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/item_states.rs b/src/database/item_states.rs new file mode 100644 index 0000000..456bbf8 --- /dev/null +++ b/src/database/item_states.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use maud::{html, Markup, Render}; +use sqlx::{query, PgPool}; + +use crate::frontend::templates::helpers::Colour; + +use super::item_events::ItemEvent; + +#[derive(Clone, Copy, Debug, sqlx::Type)] +#[sqlx(rename_all = "snake_case", type_name = "item_state")] +pub enum ItemState { + Borrowed, + Inactive, + Loaned, + Owned, +} + +impl ItemState { + pub fn colour(&self) -> Colour { + match self { + ItemState::Borrowed => Colour::Warning, + ItemState::Inactive => Colour::Secondary, + ItemState::Loaned => Colour::Danger, + ItemState::Owned => Colour::Primary, + } + } +} + +impl Render for ItemState { + fn render(&self) -> Markup { + html! { + span .badge.(self.colour().text_bg()) { + @match self { + ItemState::Borrowed => "borrowed", + ItemState::Inactive => "inactive", + ItemState::Loaned => "loaned", + ItemState::Owned => "owned", + } + } + } + } +} + +#[derive(Clone)] +pub struct ItemStateRepository { + pool: PgPool, +} + +impl ItemStateRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub async fn possible_events( + &self, + state: ItemState, + ) -> sqlx::Result> { + query!( + r#"SELECT + event AS "event: ItemEvent", + next AS "next: ItemState" + FROM item_events_transitions + WHERE state = $1"#, + state as ItemState + ) + .map(|row| (row.event, row.next)) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/items/add.rs b/src/database/items/add.rs new file mode 100644 index 0000000..ef30ad0 --- /dev/null +++ b/src/database/items/add.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::Deserialize; +use sqlx::query_scalar; +use uuid::Uuid; + +use super::ItemRepository; + +pub fn default_quantity() -> usize { + 1 +} + +#[derive(Deserialize)] +pub struct ItemAddForm { + #[serde(default = "default_quantity")] + pub quantity: usize, + pub name: Option, + pub parent: Option, + pub class: Uuid, + pub original_packaging: Option, + pub description: String, +} + +#[derive(Deserialize)] +pub struct ItemAddFormPrefilled { + pub quantity: Option, + pub name: Option, + pub parent: Option, + pub class: Option, + pub original_packaging: Option, + pub description: Option, +} + +impl ItemRepository { + pub async fn add(&self, data: ItemAddForm) -> sqlx::Result> { + if data.quantity == 1 { + query_scalar!( + "INSERT INTO items (name, parent, class, original_packaging, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id", + data.name, + data.parent, + data.class, + data.original_packaging, + data.description + ) + .fetch_one(&self.pool) + .await + .map(|id| vec![id]) + } else { + query_scalar!( + "INSERT INTO items (name, parent, class, original_packaging, description) + SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[]) + RETURNING id", + &vec![data.name; data.quantity] as &[Option], + &vec![data.parent; data.quantity] as &[Option], + &vec![data.class; data.quantity], + &vec![data.original_packaging; data.quantity] as &[Option], + &vec![data.description; data.quantity] + ) + .fetch_all(&self.pool) + .await + } + } +} diff --git a/src/database/items/datalist.rs b/src/database/items/datalist.rs new file mode 100644 index 0000000..765e5ae --- /dev/null +++ b/src/database/items/datalist.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; + +use super::{ItemName, ItemRepository}; +use crate::frontend::templates::datalist::{Datalist, DatalistOption}; + +impl ItemRepository { + pub async fn datalist(&self) -> sqlx::Result { + Ok(Datalist { + name: "items".to_string(), + link_prefix: Some("/item/".to_string()), + options: query!( + r#"SELECT items.id, items.name, item_classes.name AS "class_name" + FROM items + JOIN item_classes + ON items.class = item_classes.id"# + ) + .fetch_all(&self.pool) + .await? + .into_iter() + .map(|row| DatalistOption { + value: row.id.to_string(), + text: Box::new(ItemName::new(row.name.as_ref(), &row.class_name)), + }) + .collect(), + }) + } +} diff --git a/src/database/items/delete.rs b/src/database/items/delete.rs new file mode 100644 index 0000000..5ad5649 --- /dev/null +++ b/src/database/items/delete.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; +use uuid::Uuid; + +use super::ItemRepository; + +impl ItemRepository { + pub async fn delete(&self, id: Uuid) -> sqlx::Result<()> { + query!("DELETE FROM items WHERE id = $1", id) + .execute(&self.pool) + .await?; + + Ok(()) + } +} diff --git a/src/database/items/edit.rs b/src/database/items/edit.rs new file mode 100644 index 0000000..91b3bcd --- /dev/null +++ b/src/database/items/edit.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::Deserialize; +use sqlx::query; +use uuid::Uuid; + +use super::ItemRepository; + +#[derive(Deserialize)] +pub struct ItemEditForm { + pub name: Option, + pub parent: Option, + pub class: Uuid, + pub original_packaging: Option, + pub description: String, +} + +impl ItemRepository { + pub async fn edit_form(&self, id: Uuid) -> sqlx::Result { + query!( + r#"SELECT + items.name, + items.parent, + items.class, + item_classes.name AS "class_name", + items.original_packaging, + items.description + FROM items + JOIN item_classes + ON items.class = item_classes.id + WHERE items.id = $1"#, + id + ) + .map(|row| ItemEditForm { + name: row.name, + parent: row.parent, + class: row.class, + original_packaging: row.original_packaging, + description: row.description, + }) + .fetch_one(&self.pool) + .await + } + + pub async fn edit(&self, id: Uuid, data: &ItemEditForm) -> sqlx::Result<()> { + query!( + "UPDATE items + SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6 + WHERE id = $1", + id, + data.name, + data.parent, + data.class, + data.original_packaging, + data.description + ) + .execute(&self.pool) + .await?; + + Ok(()) + } +} diff --git a/src/database/items/label.rs b/src/database/items/label.rs new file mode 100644 index 0000000..8e73b3d --- /dev/null +++ b/src/database/items/label.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query_as; +use uuid::Uuid; + +use super::ItemRepository; +use crate::label::LabelPage; + +impl ItemRepository { + pub async fn label_pages(&self, ids: &[Uuid]) -> sqlx::Result> { + query_as!( + LabelPage, + r#"SELECT id AS "id?", to_char(short_id, '000000') AS "short_id?" + FROM items + WHERE id = ANY ($1)"#, + ids + ) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/items/list.rs b/src/database/items/list.rs new file mode 100644 index 0000000..499518e --- /dev/null +++ b/src/database/items/list.rs @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; +use uuid::Uuid; + +use super::ItemRepository; +use super::{ItemName, ItemPreview}; +use crate::database::item_states::ItemState; + +pub struct ItemListEntry { + pub id: Uuid, + pub name: ItemName, + pub class: Uuid, + pub class_name: String, + pub parents: Vec, + pub state: ItemState, +} + +impl ItemRepository { + pub async fn list(&self) -> sqlx::Result> { + query!( + r#" + WITH RECURSIVE cte AS ( + SELECT + id, + ARRAY[]::UUID[] AS parents, + ARRAY[]::VARCHAR[] AS parent_names, + ARRAY[]::VARCHAR[] AS parent_class_names + FROM items + WHERE parent IS NULL + + UNION ALL + + SELECT + items.id, + cte.parents || items.parent, + cte.parent_names || parent.name, + cte.parent_class_names || parent_class.name + FROM cte + JOIN items + ON items.parent = cte.id + JOIN items AS "parent" + ON parent.id = cte.id + JOIN item_classes AS "parent_class" + ON parent.class = parent_class.id + ) + SELECT + cte.id AS "id!", + items.name, + items.class, + item_classes.name AS "class_name", + cte.parents AS "parents!", + cte.parent_names AS "parent_names!: Vec>", + cte.parent_class_names AS "parent_class_names!", + item_states.state AS "state!: ItemState" + FROM cte + JOIN items + ON cte.id = items.id + JOIN item_classes + ON items.class = item_classes.id + JOIN item_states + ON items.id = item_states.item + ORDER BY items.created_at + "# + ) + .map(|row| ItemListEntry { + id: row.id, + name: ItemName::new(row.name.as_ref(), &row.class_name), + class: row.class, + class_name: row.class_name, + parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names) + .map(|(id, name, class_name)| { + ItemPreview::from_parts(id, name.as_ref(), &class_name) + }) + .collect(), + state: row.state, + }) + .fetch_all(&self.pool) + .await + } + + pub async fn previews(&self) -> sqlx::Result> { + query!( + r#"SELECT items.id, items.name, item_classes.name AS "class_name" + FROM items + JOIN item_classes + ON items.class = item_classes.id"# + ) + .map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/items/mod.rs b/src/database/items/mod.rs new file mode 100644 index 0000000..40e3767 --- /dev/null +++ b/src/database/items/mod.rs @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod add; +mod datalist; +mod delete; +mod edit; +mod label; +mod list; +mod show; + +use sqlx::{query, PgPool}; +use uuid::Uuid; + +use crate::database::item_states::ItemState; + +pub use add::{default_quantity, ItemAddForm, ItemAddFormPrefilled}; +pub use edit::ItemEditForm; + +#[derive(Clone)] +pub struct ItemRepository { + pool: PgPool, +} + +impl ItemRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +// Common + +#[derive(Clone)] +pub enum ItemName { + Item(String), + Class(String), + None, +} + +impl ItemName { + pub fn new(item_name: Option<&String>, class_name: &String) -> Self { + if let Some(ref name) = item_name { + Self::Item(name.to_string()) + } else { + Self::Class(class_name.to_string()) + } + } + + pub fn terse(self) -> Self { + match self { + Self::Item(_) => self, + Self::Class(_) | Self::None => Self::None, + } + } +} + +pub struct ItemPreview { + pub id: Uuid, + pub name: ItemName, + pub state: Option, +} + +impl ItemPreview { + pub fn new(id: Uuid, name: ItemName) -> Self { + Self { + id, + name, + state: None, + } + } + + pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self { + Self { + id, + name: ItemName::new(item_name, class_name), + state: None, + } + } + + pub fn with_state(mut self, state: ItemState) -> Self { + self.state = Some(state); + self + } +} + +impl ItemRepository { + pub async fn name(&self, id: Uuid) -> sqlx::Result { + query!( + r#"SELECT + items.name, + item_classes.name AS "class_name" + FROM items + JOIN item_classes + ON items.class = item_classes.id + WHERE items.id = $1"#, + id + ) + .map(|row| ItemName::new(row.name.as_ref(), &row.class_name)) + .fetch_one(&self.pool) + .await + } + + pub async fn parents(&self, id: Uuid) -> sqlx::Result> { + query!( + r#"SELECT items.id, items.name, item_classes.name AS "class_name" + FROM items + JOIN unnest((SELECT parents FROM item_tree WHERE id = $1)) + WITH ORDINALITY AS parents(id, n) + ON items.id = parents.id + JOIN item_classes + ON items.class = item_classes.id + ORDER BY parents.n"#, + id + ) + .map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) + .fetch_all(&self.pool) + .await + } + + pub async fn children(&self, id: Uuid) -> sqlx::Result> { + query!( + r#"SELECT + items.id, + items.name, + item_classes.name AS "class_name", + item_states.state AS "state!: ItemState" + FROM items + JOIN item_classes + ON items.class = item_classes.id + JOIN item_states + ON items.id = item_states.item + WHERE items.parent = $1"#, + id + ) + .map(|row| { + ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name) + .with_state(row.state) + }) + .fetch_all(&self.pool) + .await + } + + pub async fn original_packaging_of(&self, id: Uuid) -> sqlx::Result> { + query!( + r#"SELECT + items.id, + items.name, + item_classes.name AS "class_name", + item_states.state AS "state!: ItemState" + FROM items + JOIN item_classes + ON items.class = item_classes.id + JOIN item_states + ON items.id = item_states.item + WHERE items.original_packaging = $1"#, + id + ) + .map(|row| { + ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name) + .with_state(row.state) + }) + .fetch_all(&self.pool) + .await + } +} diff --git a/src/database/items/show.rs b/src/database/items/show.rs new file mode 100644 index 0000000..b166661 --- /dev/null +++ b/src/database/items/show.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::query; +use uuid::Uuid; + +use super::ItemRepository; +use super::{ItemName, ItemPreview}; +use crate::database::item_states::ItemState; + +pub struct ItemDetails { + pub id: Uuid, + pub short_id: i32, + pub name: ItemName, + pub class: Uuid, + pub class_name: String, + pub original_packaging: Option, + pub description: String, + pub state: ItemState, +} + +impl ItemRepository { + pub async fn details(&self, id: Uuid) -> sqlx::Result { + query!( + r#"SELECT + items.id, + items.short_id, + items.name, + items.class, + item_classes.name AS "class_name", + items.original_packaging, + op.name AS "original_packaging_name?", + op_class.name AS "original_packaging_class_name?", + op_state.state AS "original_packaging_state: ItemState", + items.description, + item_states.state AS "state!: ItemState" + FROM items + JOIN item_classes + ON items.class = item_classes.id + JOIN item_states + ON items.id = item_states.item + LEFT JOIN items AS "op" + ON items.original_packaging = op.id + LEFT JOIN item_classes AS "op_class" + ON op.class = op_class.id + LEFT JOIN item_states AS "op_state" + ON op.id = op_state.item + WHERE items.id = $1"#, + id + ) + .map(|row| ItemDetails { + id: row.id, + short_id: row.short_id, + name: ItemName::new(row.name.as_ref(), &row.class_name), + class: row.class, + class_name: row.class_name, + original_packaging: row.original_packaging.map(|id| { + ItemPreview::from_parts( + id, + row.original_packaging_name.as_ref(), + &row.original_packaging_class_name.unwrap(), + ) + .with_state(row.original_packaging_state.unwrap()) + }), + description: row.description, + state: row.state, + }) + .fetch_one(&self.pool) + .await + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..a200dea --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod item_classes; +pub mod item_events; +pub mod item_states; +pub mod items; +pub mod search; + +pub use item_classes::ItemClassRepository; +pub use item_events::ItemEventRepository; +pub use item_states::ItemStateRepository; +pub use items::ItemRepository; diff --git a/src/database/search.rs b/src/database/search.rs new file mode 100644 index 0000000..b6142f8 --- /dev/null +++ b/src/database/search.rs @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sqlx::{query, query_scalar, PgPool}; +use uuid::Uuid; + +pub enum Entity { + Item(Uuid), + ItemClass(Uuid), +} + +pub async fn search_id(pool: &PgPool, id: &str) -> sqlx::Result> { + if let Ok(id) = Uuid::parse_str(id) { + query!( + r#"SELECT type as "type!" + FROM (SELECT id, 'item' AS "type" FROM items + UNION ALL + SELECT id, 'item_class' AS "type" FROM item_classes) id_mapping + WHERE id = $1"#, + id + ) + .map(|row| match row.r#type.as_str() { + "item" => Entity::Item(id), + "item_class" => Entity::ItemClass(id), + _ => unreachable!("database returned impossible type"), + }) + .fetch_optional(pool) + .await + } else if let Ok(short_id) = id.parse::() { + Ok( + query_scalar!("SELECT id FROM items WHERE short_id = $1", short_id) + .fetch_one(pool) + .await + .map(|id| Some(Entity::Item(id))) + .unwrap_or(None), + ) + } else { + Ok(None) + } +} diff --git a/src/frontend/item/add.rs b/src/frontend/item/add.rs index f5eff99..af381bd 100644 --- a/src/frontend/item/add.rs +++ b/src/frontend/item/add.rs @@ -7,52 +7,31 @@ use std::fmt::Display; use actix_identity::Identity; use actix_web::{error, get, post, web, HttpRequest, Responder}; use maud::html; -use serde::Deserialize; -use sqlx::{query_scalar, PgPool}; -use uuid::Uuid; -use crate::frontend::templates::{self, datalist, forms, helpers::PageActionGroup, TemplateConfig}; +use crate::database::{ + items::{default_quantity, ItemAddForm, ItemAddFormPrefilled}, + ItemClassRepository, ItemRepository, +}; +use crate::frontend::templates::{self, forms, helpers::PageActionGroup, TemplateConfig}; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get).service(post); } -fn default_quantity() -> usize { - 1 -} - -#[derive(Debug, Deserialize)] -struct NewItemForm { - #[serde(default = "default_quantity")] - quantity: usize, - name: Option, - parent: Option, - class: Uuid, - original_packaging: Option, - description: String, -} - -#[derive(Debug, Deserialize)] -struct NewItemFormPrefilled { - quantity: Option, - name: Option, - parent: Option, - class: Option, - original_packaging: Option, - description: Option, -} - #[get("/items/add")] async fn get( - pool: web::Data, - form: web::Query, + item_class_repo: web::Data, + item_repo: web::Data, + form: web::Query, user: Identity, ) -> actix_web::Result { - let datalist_items = datalist::items(&pool) + let datalist_items = item_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; - let datalist_item_classes = datalist::item_classes(&pool) + let datalist_item_classes = item_class_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; @@ -123,69 +102,47 @@ async fn get( #[post("/items/add")] async fn post( req: HttpRequest, - data: web::Form, - pool: web::Data, + data: web::Form, + item_repo: web::Data, user: Identity, ) -> actix_web::Result { let data = data.into_inner(); - if data.quantity == 1 { - query_scalar!( - "INSERT INTO items (name, parent, class, original_packaging, description) - VALUES ($1, $2, $3, $4, $5) - RETURNING id", - data.name, - data.parent, - data.class, - data.original_packaging, - data.description - ) - .fetch_one(pool.as_ref()) + + let ids = item_repo + .add(data) .await - .map_err(error::ErrorInternalServerError) - .map(|id| { - web::Redirect::to("/item/".to_owned() + &id.to_string()) + .map_err(error::ErrorInternalServerError)?; + + if ids.len() == 1 { + Ok( + web::Redirect::to("/item/".to_owned() + &ids.first().unwrap().to_string()) .see_other() .respond_to(&req) - .map_into_boxed_body() - }) - } else { - query_scalar!( - "INSERT INTO items (name, parent, class, original_packaging, description) - SELECT * FROM UNNEST($1::VARCHAR[], $2::UUID[], $3::UUID[], $4::UUID[], $5::VARCHAR[]) - RETURNING id", - &vec![data.name; data.quantity] as &[Option], - &vec![data.parent; data.quantity] as &[Option], - &vec![data.class; data.quantity], - &vec![data.original_packaging; data.quantity] as &[Option], - &vec![data.description; data.quantity] + .map_into_boxed_body(), ) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError) - .map(|ids| { - templates::base( - TemplateConfig { - path: "/items/add", - title: Some("Added Items"), - page_title: Some(Box::new("Added Items")), - page_actions: vec![PageActionGroup::generate_labels(&ids)], - user: Some(user), - ..Default::default() - }, - html! { - ul { - @for id in &ids { - li { - a href={ "/item/" (id) } { (id) } - } + } else { + Ok(templates::base( + TemplateConfig { + path: "/items/add", + title: Some("Added Items"), + page_title: Some(Box::new("Added Items")), + page_actions: vec![PageActionGroup::generate_labels(&ids)], + user: Some(user), + ..Default::default() + }, + html! { + ul { + @for id in &ids { + li { + a href={ "/item/" (id) } { (id) } } } + } - a href="/items" { "Back to all items" } - }, - ) - .respond_to(&req) - .map_into_boxed_body() - }) + a href="/items" { "Back to all items" } + }, + ) + .respond_to(&req) + .map_into_boxed_body()) } } diff --git a/src/frontend/item/delete.rs b/src/frontend/item/delete.rs index 7da12b2..3ec09a2 100644 --- a/src/frontend/item/delete.rs +++ b/src/frontend/item/delete.rs @@ -4,23 +4,24 @@ use actix_identity::Identity; use actix_web::{error, post, web, Responder}; -use sqlx::{query, PgPool}; use uuid::Uuid; +use crate::database::ItemRepository; + pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(post); } #[post("/item/{id}/delete")] async fn post( - pool: web::Data, + item_repo: web::Data, path: web::Path, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - query!("DELETE FROM items WHERE id = $1", id) - .execute(pool.as_ref()) + item_repo + .delete(id) .await .map_err(error::ErrorInternalServerError)?; diff --git a/src/frontend/item/edit.rs b/src/frontend/item/edit.rs index cf90bd0..f18a007 100644 --- a/src/frontend/item/edit.rs +++ b/src/frontend/item/edit.rs @@ -7,68 +7,40 @@ use std::fmt::Display; use actix_identity::Identity; use actix_web::{error, get, post, web, Responder}; use maud::html; -use serde::Deserialize; -use sqlx::{query, PgPool}; use uuid::Uuid; -use crate::frontend::templates::{self, datalist, forms, helpers::ItemName, TemplateConfig}; +use crate::database::{items::ItemEditForm, ItemClassRepository, ItemRepository}; +use crate::frontend::templates::{self, forms, TemplateConfig}; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get).service(post); } -#[derive(Deserialize)] -struct ItemEditForm { - name: Option, - parent: Option, - class: Uuid, - original_packaging: Option, - description: String, -} - #[get("/item/{id}/edit")] async fn get( - pool: web::Data, + item_repo: web::Data, + item_class_repo: web::Data, path: web::Path, user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let (item_name, form) = query!( - r#"SELECT - items.name, - items.parent, - items.class, - item_classes.name AS "class_name", - items.original_packaging, - items.description - FROM items - JOIN item_classes - ON items.class = item_classes.id - WHERE items.id = $1"#, - id - ) - .map(|row| { - ( - ItemName::new(row.name.as_ref(), &row.class_name), - ItemEditForm { - name: row.name, - parent: row.parent, - class: row.class, - original_packaging: row.original_packaging, - description: row.description, - }, - ) - }) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; - - let datalist_items = datalist::items(&pool) + let item_name = item_repo + .name(id) + .await + .map_err(error::ErrorInternalServerError)?; + let form = item_repo + .edit_form(id) .await .map_err(error::ErrorInternalServerError)?; - let datalist_item_classes = datalist::item_classes(&pool) + let datalist_items = item_repo + .datalist() + .await + .map_err(error::ErrorInternalServerError)?; + + let datalist_item_classes = item_class_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; @@ -149,27 +121,17 @@ async fn get( #[post("/item/{id}/edit")] async fn post( - pool: web::Data, + item_repo: web::Data, path: web::Path, data: web::Form, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - query!( - "UPDATE items - SET name = $2, parent = $3, class = $4, original_packaging = $5, description = $6 - WHERE id = $1", - id, - data.name, - data.parent, - data.class, - data.original_packaging, - data.description - ) - .execute(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + item_repo + .edit(id, &data) + .await + .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to("/item/".to_owned() + &id.to_string()).see_other()) } diff --git a/src/frontend/item/event.rs b/src/frontend/item/event.rs index ffcc2e6..c88a609 100644 --- a/src/frontend/item/event.rs +++ b/src/frontend/item/event.rs @@ -4,12 +4,10 @@ use actix_identity::Identity; use actix_web::{error, post, web, Responder}; -use serde::Deserialize; -use sqlx::{query, query_scalar, PgPool}; -use time::Date; use uuid::Uuid; -use crate::frontend::templates::helpers::ItemEvent; +use crate::database::item_events::EventAddForm; +use crate::database::ItemEventRepository; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(delete).service(add); @@ -18,49 +16,33 @@ pub fn config(cfg: &mut web::ServiceConfig) { // not the best HTTP method, but there is no (non-JS) way of sending a DELETE request #[post("/items/event/{id}/delete")] async fn delete( - pool: web::Data, + item_event_repo: web::Data, path: web::Path, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let item_id = query_scalar!("DELETE FROM item_events WHERE id = $1 RETURNING item", id) - .fetch_one(pool.as_ref()) + let item_id = item_event_repo + .delete(id) .await .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to(format!("/item/{}", item_id)).see_other()) } -#[derive(Deserialize)] -struct NewEvent { - date: Date, - event: ItemEvent, - description: String, -} - #[post("/item/{id}/events/add")] async fn add( - pool: web::Data, + item_event_repo: web::Data, path: web::Path, - data: web::Form, + data: web::Form, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let data = data.into_inner(); - - query!( - "INSERT INTO item_events (item, date, event, description) - VALUES ($1, $2, $3, $4)", - id, - data.date, - data.event as ItemEvent, - data.description - ) - .execute(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + item_event_repo + .add(id, data.into_inner()) + .await + .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to(format!("/item/{}", id)).see_other()) } diff --git a/src/frontend/item/list.rs b/src/frontend/item/list.rs index bcc067b..aa4afc2 100644 --- a/src/frontend/item/list.rs +++ b/src/frontend/item/list.rs @@ -5,14 +5,11 @@ use actix_identity::Identity; use actix_web::{error, get, web, Responder}; use maud::html; -use sqlx::{query, PgPool}; -use uuid::Uuid; +use crate::database::{items::ItemPreview, ItemRepository}; use crate::frontend::templates::{ self, - helpers::{ - Colour, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, PageActionMethod, - }, + helpers::{Colour, PageAction, PageActionGroup, PageActionMethod}, TemplateConfig, }; @@ -20,75 +17,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } -struct ItemListEntry { - id: Uuid, - name: ItemName, - class: Uuid, - class_name: String, - parents: Vec, - state: ItemState, -} - #[get("/items")] -async fn get(pool: web::Data, user: Identity) -> actix_web::Result { - let items = query!( - r#" - WITH RECURSIVE cte AS ( - SELECT - id, - ARRAY[]::UUID[] AS parents, - ARRAY[]::VARCHAR[] AS parent_names, - ARRAY[]::VARCHAR[] AS parent_class_names - FROM items - WHERE parent IS NULL - - UNION - - SELECT - items.id, - cte.parents || items.parent, - cte.parent_names || parent.name, - cte.parent_class_names || parent_class.name - FROM cte - JOIN items - ON items.parent = cte.id - JOIN items AS "parent" - ON parent.id = cte.id - JOIN item_classes AS "parent_class" - ON parent.class = parent_class.id - ) - SELECT - cte.id AS "id!", - items.name, - items.class, - item_classes.name AS "class_name", - cte.parents AS "parents!", - cte.parent_names AS "parent_names!: Vec>", - cte.parent_class_names AS "parent_class_names!", - item_states.state AS "state!: ItemState" - FROM cte - JOIN items - ON cte.id = items.id - JOIN item_classes - ON items.class = item_classes.id - JOIN item_states - ON items.id = item_states.item - ORDER BY items.created_at - "# - ) - .map(|row| ItemListEntry { - id: row.id, - name: ItemName::new(row.name.as_ref(), &row.class_name), - class: row.class, - class_name: row.class_name, - parents: itertools::izip!(row.parents, row.parent_names, row.parent_class_names) - .map(|(id, name, class_name)| ItemPreview::from_parts(id, name.as_ref(), &class_name)) - .collect(), - state: row.state, - }) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; +async fn get( + item_repo: web::Data, + user: Identity, +) -> actix_web::Result { + let items = item_repo + .list() + .await + .map_err(error::ErrorInternalServerError)?; Ok(templates::base( TemplateConfig { diff --git a/src/frontend/item/show.rs b/src/frontend/item/show.rs index ecf0485..49ab444 100644 --- a/src/frontend/item/show.rs +++ b/src/frontend/item/show.rs @@ -6,16 +6,13 @@ use actix_identity::Identity; use actix_web::{error, get, web, Responder}; use maud::html; use serde_variant::to_variant_name; -use sqlx::{query, query_as, PgPool}; -use time::{Date, OffsetDateTime}; +use time::OffsetDateTime; use uuid::Uuid; +use crate::database::{ItemEventRepository, ItemRepository, ItemStateRepository}; use crate::frontend::templates::{ self, forms, - helpers::{ - Colour, ItemEvent, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, - PageActionMethod, - }, + helpers::{Colour, PageAction, PageActionGroup, PageActionMethod}, TemplateConfig, }; @@ -23,159 +20,45 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } -struct ItemDetails { - id: Uuid, - short_id: i32, - name: ItemName, - class: Uuid, - class_name: String, - original_packaging: Option, - description: String, - state: ItemState, -} - -struct ItemEventDetails { - id: i32, - date: Date, - event: ItemEvent, - description: String, -} - #[get("/item/{id}")] async fn get( - pool: web::Data, + item_repo: web::Data, + item_event_repo: web::Data, + item_state_repo: web::Data, path: web::Path, user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let item = query!( - r#"SELECT - items.id, - items.short_id, - items.name, - items.class, - item_classes.name AS "class_name", - items.original_packaging, - op.name AS "original_packaging_name?", - op_class.name AS "original_packaging_class_name?", - op_state.state AS "original_packaging_state: ItemState", - items.description, - item_states.state AS "state!: ItemState" - FROM items - JOIN item_classes - ON items.class = item_classes.id - JOIN item_states - ON items.id = item_states.item - LEFT JOIN items AS "op" - ON items.original_packaging = op.id - LEFT JOIN item_classes AS "op_class" - ON op.class = op_class.id - LEFT JOIN item_states AS "op_state" - ON op.id = op_state.item - WHERE items.id = $1"#, - id - ) - .map(|row| ItemDetails { - id: row.id, - short_id: row.short_id, - name: ItemName::new(row.name.as_ref(), &row.class_name), - class: row.class, - class_name: row.class_name, - original_packaging: row.original_packaging.map(|id| { - ItemPreview::from_parts( - id, - row.original_packaging_name.as_ref(), - &row.original_packaging_class_name.unwrap(), - ) - .with_state(row.original_packaging_state.unwrap()) - }), - description: row.description, - state: row.state, - }) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let item = item_repo + .details(id) + .await + .map_err(error::ErrorInternalServerError)?; - let events = query_as!( - ItemEventDetails, - r#"SELECT id, date, event AS "event: ItemEvent", description FROM item_events WHERE item = $1"#, - id - ) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let events = item_event_repo + .for_item(id) + .await + .map_err(error::ErrorInternalServerError)?; - let possible_events = query!( - r#"SELECT - event AS "event: ItemEvent", - next AS "next: ItemState" - FROM item_events_transitions - WHERE state = $1"#, - item.state as ItemState - ) - .map(|row| (row.event, row.next)) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let possible_events = item_state_repo + .possible_events(item.state) + .await + .map_err(error::ErrorInternalServerError)?; - let parents = query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" - FROM items - JOIN unnest((SELECT parents FROM item_tree WHERE id = $1)) - WITH ORDINALITY AS parents(id, n) - ON items.id = parents.id - JOIN item_classes - ON items.class = item_classes.id - ORDER BY parents.n"#, - id - ) - .map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let parents = item_repo + .parents(id) + .await + .map_err(error::ErrorInternalServerError)?; - let children = query!( - r#"SELECT - items.id, - items.name, - item_classes.name AS "class_name", - item_states.state AS "state!: ItemState" - FROM items - JOIN item_classes - ON items.class = item_classes.id - JOIN item_states - ON items.id = item_states.item - WHERE items.parent = $1"#, - id - ) - .map(|row| { - ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state) - }) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let children = item_repo + .children(id) + .await + .map_err(error::ErrorInternalServerError)?; - let original_packaging_of = query!( - r#"SELECT - items.id, - items.name, - item_classes.name AS "class_name", - item_states.state AS "state!: ItemState" - FROM items - JOIN item_classes - ON items.class = item_classes.id - JOIN item_states - ON items.id = item_states.item - WHERE items.original_packaging = $1"#, - id - ) - .map(|row| { - ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state) - }) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let original_packaging_of = item_repo + .original_packaging_of(id) + .await + .map_err(error::ErrorInternalServerError)?; let mut title = item.name.to_string(); title.push_str(" – Item Details"); diff --git a/src/frontend/item_class/add.rs b/src/frontend/item_class/add.rs index 356e678..f323300 100644 --- a/src/frontend/item_class/add.rs +++ b/src/frontend/item_class/add.rs @@ -7,37 +7,25 @@ use std::fmt::Display; use actix_identity::Identity; use actix_web::{error, get, post, web, Responder}; use maud::html; -use serde::Deserialize; -use sqlx::{query_scalar, PgPool}; -use uuid::Uuid; -use crate::frontend::templates::{self, datalist, forms, TemplateConfig}; +use crate::database::{ + item_classes::{ItemClassAddForm, ItemClassAddFormPrefilled}, + ItemClassRepository, +}; +use crate::frontend::templates::{self, forms, TemplateConfig}; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get).service(post); } -#[derive(Debug, Deserialize)] -struct NewItemClassForm { - name: String, - parent: Option, - description: String, -} - -#[derive(Debug, Deserialize)] -struct NewItemClassFormPrefilled { - name: Option, - parent: Option, - description: Option, -} - #[get("/item-classes/add")] async fn get( - pool: web::Data, - form: web::Query, + item_class_repo: web::Data, + form: web::Query, user: Identity, ) -> actix_web::Result { - let datalist_item_classes = datalist::item_classes(&pool) + let datalist_item_classes = item_class_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; @@ -86,23 +74,16 @@ async fn get( #[post("/item-classes/add")] async fn post( - data: web::Form, - pool: web::Data, + data: web::Form, + item_class_repo: web::Data, _user: Identity, ) -> actix_web::Result { let data = data.into_inner(); - let id = query_scalar!( - "INSERT INTO item_classes (name, parent, description) - VALUES ($1, $2, $3) - RETURNING id", - data.name, - data.parent, - data.description - ) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let id = item_class_repo + .add(data) + .await + .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other()) } diff --git a/src/frontend/item_class/delete.rs b/src/frontend/item_class/delete.rs index cc4cb57..e416247 100644 --- a/src/frontend/item_class/delete.rs +++ b/src/frontend/item_class/delete.rs @@ -4,23 +4,24 @@ use actix_identity::Identity; use actix_web::{error, post, web, Responder}; -use sqlx::{query, PgPool}; use uuid::Uuid; +use crate::database::ItemClassRepository; + pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(post); } #[post("/item-class/{id}/delete")] async fn post( - pool: web::Data, + item_class_repo: web::Data, path: web::Path, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - query!("DELETE FROM item_classes WHERE id = $1", id) - .execute(pool.as_ref()) + item_class_repo + .delete(id) .await .map_err(error::ErrorInternalServerError)?; diff --git a/src/frontend/item_class/edit.rs b/src/frontend/item_class/edit.rs index ee1473d..cc93ece 100644 --- a/src/frontend/item_class/edit.rs +++ b/src/frontend/item_class/edit.rs @@ -7,41 +7,31 @@ use std::fmt::Display; use actix_identity::Identity; use actix_web::{error, get, post, web, Responder}; use maud::html; -use serde::Deserialize; -use sqlx::{query, query_as, PgPool}; use uuid::Uuid; -use crate::frontend::templates::{self, datalist, forms, TemplateConfig}; +use crate::database::item_classes::ItemClassEditForm; +use crate::database::ItemClassRepository; +use crate::frontend::templates::{self, forms, TemplateConfig}; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get).service(post); } -#[derive(Deserialize)] -struct ItemClassEditForm { - name: String, - parent: Option, - description: String, -} - #[get("/item-class/{id}/edit")] async fn get( - pool: web::Data, + item_class_repo: web::Data, path: web::Path, user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let form = query_as!( - ItemClassEditForm, - "SELECT name, parent, description FROM item_classes WHERE id = $1", - id - ) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let form = item_class_repo + .edit_form(id) + .await + .map_err(error::ErrorInternalServerError)?; - let datalist_item_classes = datalist::item_classes(&pool) + let datalist_item_classes = item_class_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; @@ -102,25 +92,17 @@ async fn get( #[post("/item-class/{id}/edit")] async fn post( - pool: web::Data, + item_class_repo: web::Data, path: web::Path, data: web::Form, _user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - query!( - "UPDATE item_classes - SET name = $2, parent = $3, description = $4 - WHERE id = $1", - id, - data.name, - data.parent, - data.description - ) - .execute(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + item_class_repo + .edit(id, &data) + .await + .map_err(error::ErrorInternalServerError)?; Ok(web::Redirect::to("/item-class/".to_owned() + &id.to_string()).see_other()) } diff --git a/src/frontend/item_class/list.rs b/src/frontend/item_class/list.rs index 498c397..9708cfa 100644 --- a/src/frontend/item_class/list.rs +++ b/src/frontend/item_class/list.rs @@ -5,12 +5,12 @@ use actix_identity::Identity; use actix_web::{error, get, web, Responder}; use maud::html; -use sqlx::{query, PgPool}; -use uuid::Uuid; +use crate::database::item_classes::ItemClassPreview; +use crate::database::ItemClassRepository; use crate::frontend::templates::{ self, - helpers::{Colour, ItemClassPreview, PageAction, PageActionGroup, PageActionMethod}, + helpers::{Colour, PageAction, PageActionGroup, PageActionMethod}, TemplateConfig, }; @@ -18,32 +18,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } -struct ItemClassListEntry { - id: Uuid, - name: String, - parent: Option, -} - #[get("/item-classes")] -async fn get(pool: web::Data, user: Identity) -> actix_web::Result { - let item_classes = query!( - r#"SELECT class.id, class.name, class.parent, parent.name AS "parent_name?" - FROM item_classes AS "class" - LEFT JOIN item_classes AS "parent" - ON class.parent = parent.id - ORDER BY class.created_at - "# - ) - .map(|row| ItemClassListEntry { - id: row.id, - name: row.name, - parent: row - .parent - .map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())), - }) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; +async fn get( + item_class_repo: web::Data, + user: Identity, +) -> actix_web::Result { + let item_classes = item_class_repo + .list() + .await + .map_err(error::ErrorInternalServerError)?; Ok(templates::base( TemplateConfig { diff --git a/src/frontend/item_class/show.rs b/src/frontend/item_class/show.rs index 3423b9f..c3f92f0 100644 --- a/src/frontend/item_class/show.rs +++ b/src/frontend/item_class/show.rs @@ -5,15 +5,12 @@ use actix_identity::Identity; use actix_web::{error, get, web, Responder}; use maud::html; -use sqlx::{query, query_as, PgPool}; use uuid::Uuid; +use crate::database::ItemClassRepository; use crate::frontend::templates::{ self, - helpers::{ - Colour, ItemClassPreview, ItemPreview, ItemState, PageAction, PageActionGroup, - PageActionMethod, - }, + helpers::{Colour, PageAction, PageActionGroup, PageActionMethod}, TemplateConfig, }; @@ -21,75 +18,28 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } -struct ItemClassDetails { - id: Uuid, - name: String, - description: String, - parent: Option, -} - #[get("/item-class/{id}")] async fn get( - pool: web::Data, + item_class_repo: web::Data, path: web::Path, user: Identity, ) -> actix_web::Result { let id = path.into_inner(); - let item_class = query!( - r#"SELECT - class.id, - class.name, - class.description, - class.parent, - parent.name AS "parent_name?" - FROM item_classes AS "class" - LEFT JOIN item_classes AS "parent" - ON class.parent = parent.id - WHERE class.id = $1"#, - id - ) - .map(|row| ItemClassDetails { - id: row.id, - name: row.name, - description: row.description, - parent: row - .parent - .map(|id| ItemClassPreview::new(id, row.parent_name.unwrap())), - }) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let item_class = item_class_repo + .details(id) + .await + .map_err(error::ErrorInternalServerError)?; - let children = query_as!( - ItemClassPreview, - "SELECT id, name FROM item_classes WHERE parent = $1", - id - ) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let children = item_class_repo + .children(id) + .await + .map_err(error::ErrorInternalServerError)?; - let items = query!( - r#"SELECT - items.id, - items.name, - item_classes.name AS "class_name", - item_states.state AS "state!: ItemState" - FROM items - JOIN item_classes - ON items.class = item_classes.id - JOIN item_states - ON items.id = item_states.item - WHERE items.class = $1"#, - id - ) - .map(|row| { - ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name).with_state(row.state) - }) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; + let items = item_class_repo + .items(id) + .await + .map_err(error::ErrorInternalServerError)?; let mut title = item_class.name.clone(); title.push_str(" – Item Details"); diff --git a/src/frontend/jump.rs b/src/frontend/jump.rs index 2f8ab13..c3f313e 100644 --- a/src/frontend/jump.rs +++ b/src/frontend/jump.rs @@ -5,19 +5,14 @@ use actix_identity::Identity; use actix_web::{error, get, web, Responder}; use serde::Deserialize; -use sqlx::{query, query_scalar, PgPool}; -use uuid::Uuid; +use sqlx::PgPool; + +use crate::database::search::{search_id, Entity}; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(get); } -#[derive(Deserialize)] -pub enum EntityType { - Item, - ItemClass, -} - #[derive(Deserialize)] struct JumpData { id: String, @@ -29,45 +24,18 @@ async fn get( data: web::Query, _user: Identity, // this endpoint leaks information about the existence of items ) -> Result { - let mut id = data.id.clone(); + let id = &data.id; - let entity_type = if let Ok(id) = Uuid::parse_str(&id) { - query!( - r#"SELECT type as "type!" - FROM (SELECT id, 'item' AS "type" FROM items - UNION ALL - SELECT id, 'item_class' AS "type" FROM item_classes) id_mapping - WHERE id = $1"#, - id - ) - .map(|row| match row.r#type.as_str() { - "item" => EntityType::Item, - "item_class" => EntityType::ItemClass, - _ => unreachable!("database returned impossible type"), - }) - .fetch_optional(pool.as_ref()) + let entity = search_id(&pool, &data.id) .await - .map_err(error::ErrorInternalServerError)? - } else if let Ok(short_id) = id.parse::() { - if let Ok(id_) = query_scalar!("SELECT id FROM items WHERE short_id = $1", short_id) - .fetch_one(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError) - { - id = id_.to_string(); - Some(EntityType::Item) - } else { - None - } - } else { - None - }; + .map_err(error::ErrorInternalServerError)?; - if let Some(prefix) = entity_type.map(|entity_type| match entity_type { - EntityType::Item => "item", - EntityType::ItemClass => "item-class", - }) { - Ok(web::Redirect::to(format!("/{prefix}/{id}")).see_other()) + if let Some(entity) = entity { + Ok(web::Redirect::to(match entity { + Entity::Item(id) => format!("/item/{id}"), + Entity::ItemClass(id) => format!("/item-class/{id}"), + }) + .see_other()) } else { Ok(web::Redirect::to(format!( "/items/add?{}", diff --git a/src/frontend/labels.rs b/src/frontend/labels.rs index e0d9796..1b249e2 100644 --- a/src/frontend/labels.rs +++ b/src/frontend/labels.rs @@ -7,11 +7,10 @@ use actix_web::{error, get, post, web, Responder}; use maud::html; use serde::Deserialize; use serde_variant::to_variant_name; -use sqlx::{query, PgPool}; use uuid::Uuid; -use super::templates::{self, datalist, TemplateConfig}; -use crate::frontend::templates::helpers::ItemPreview; +use super::templates::{self, TemplateConfig}; +use crate::database::ItemRepository; use crate::label::{Label, LabelPreset}; pub fn config(cfg: &mut web::ServiceConfig) { @@ -26,7 +25,10 @@ struct GenerateParams { preset: LabelPreset, } -async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result { +async fn generate( + item_repo: &ItemRepository, + params: GenerateParams, +) -> actix_web::Result { let ids = params .ids .split(',') @@ -35,43 +37,41 @@ async fn generate(pool: &PgPool, params: GenerateParams) -> actix_web::Result, uuid::Error>>() .map_err(error::ErrorInternalServerError)?; - Label::for_items(pool, &ids, params.preset.clone().into()) + Label::for_items(item_repo, &ids, params.preset.clone().into()) .await .map_err(error::ErrorInternalServerError) } #[post("/labels/generate")] async fn generate_post( - pool: web::Data, + item_repo: web::Data, _user: Identity, params: web::Form, ) -> impl Responder { - generate(&pool, params.into_inner()).await + generate(&item_repo, params.into_inner()).await } #[get("/labels/generate")] async fn generate_get( - pool: web::Data, + item_repo: web::Data, _user: Identity, params: web::Query, ) -> impl Responder { - generate(&pool, params.into_inner()).await + generate(&item_repo, params.into_inner()).await } #[get("/labels")] -async fn form(pool: web::Data, user: Identity) -> actix_web::Result { - let items = query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" - FROM items - JOIN item_classes - ON items.class = item_classes.id"# - ) - .map(|row| ItemPreview::from_parts(row.id, row.name.as_ref(), &row.class_name)) - .fetch_all(pool.as_ref()) - .await - .map_err(error::ErrorInternalServerError)?; +async fn form( + item_repo: web::Data, + user: Identity, +) -> actix_web::Result { + let items = item_repo + .previews() + .await + .map_err(error::ErrorInternalServerError)?; - let datalist_items = datalist::items(&pool) + let datalist_items = item_repo + .datalist() .await .map_err(error::ErrorInternalServerError)?; diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 1571c2d..94d76a3 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -7,7 +7,7 @@ mod item; mod item_class; mod jump; mod labels; -mod templates; +pub mod templates; use actix_identity::Identity; use actix_web::{get, web, Responder}; diff --git a/src/frontend/templates/datalist.rs b/src/frontend/templates/datalist.rs index d66007a..2a17829 100644 --- a/src/frontend/templates/datalist.rs +++ b/src/frontend/templates/datalist.rs @@ -3,14 +3,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use maud::{html, Markup, Render}; -use sqlx::{query, PgPool}; - -use super::helpers::ItemName; pub struct Datalist { - name: String, - options: Vec, - link_prefix: Option, + pub name: String, + pub options: Vec, + pub link_prefix: Option, } impl Datalist { @@ -32,8 +29,8 @@ impl Render for Datalist { } pub struct DatalistOption { - value: String, - text: Box, + pub value: String, + pub text: Box, } impl Render for DatalistOption { @@ -41,40 +38,3 @@ impl Render for DatalistOption { html! { option value=(self.value) { (self.text) } } } } - -pub async fn items(pool: &PgPool) -> Result { - Ok(Datalist { - name: "items".to_string(), - link_prefix: Some("/item/".to_string()), - options: query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" - FROM items - JOIN item_classes - ON items.class = item_classes.id"# - ) - .fetch_all(pool) - .await? - .into_iter() - .map(|row| DatalistOption { - value: row.id.to_string(), - text: Box::new(ItemName::new(row.name.as_ref(), &row.class_name)), - }) - .collect(), - }) -} - -pub async fn item_classes(pool: &PgPool) -> Result { - Ok(Datalist { - name: "item-classes".to_string(), - link_prefix: Some("/item-class/".to_string()), - options: query!("SELECT id, name FROM item_classes") - .fetch_all(pool) - .await? - .into_iter() - .map(|row| DatalistOption { - value: row.id.to_string(), - text: Box::new(row.name), - }) - .collect(), - }) -} diff --git a/src/frontend/templates/helpers.rs b/src/frontend/templates/helpers.rs index cc65d62..2a0f649 100644 --- a/src/frontend/templates/helpers.rs +++ b/src/frontend/templates/helpers.rs @@ -2,13 +2,14 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use std::fmt::{self, Display}; +use std::fmt; -use crate::label::LabelPreset; use maud::{html, Markup, PreEscaped, Render}; -use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::database::items::{ItemName, ItemPreview}; +use crate::label::LabelPreset; + pub enum Css<'a> { File(&'a str), #[allow(dead_code)] @@ -88,113 +89,6 @@ impl Colour { } } -#[derive(Clone)] -pub enum ItemName { - Item(String), - Class(String), - None, -} - -impl ItemName { - pub fn new(item_name: Option<&String>, class_name: &String) -> Self { - if let Some(ref name) = item_name { - Self::Item(name.to_string()) - } else { - Self::Class(class_name.to_string()) - } - } -} - -impl ItemName { - pub fn terse(self) -> Self { - match self { - Self::Item(_) => self, - Self::Class(_) | Self::None => Self::None, - } - } -} - -impl Display for ItemName { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Item(name) => write!(f, "{name}"), - Self::Class(name) => write!(f, "*{name}*"), - Self::None => write!(f, "[no name]"), - } - } -} - -impl Render for ItemName { - fn render(&self) -> Markup { - html! { - @match self { - Self::Item(name) => { (name) }, - Self::Class(name) => { em { (name) } }, - Self::None => { em { "[no name]" } }, - } - } - } -} - -pub struct ItemPreview { - pub id: Uuid, - pub name: ItemName, - pub state: Option, -} - -impl ItemPreview { - pub fn new(id: Uuid, name: ItemName) -> Self { - Self { - id, - name, - state: None, - } - } - - pub fn from_parts(id: Uuid, item_name: Option<&String>, class_name: &String) -> Self { - Self { - id, - name: ItemName::new(item_name, class_name), - state: None, - } - } - - pub fn with_state(mut self, state: ItemState) -> Self { - self.state = Some(state); - self - } -} - -impl Render for ItemPreview { - fn render(&self) -> Markup { - html! { - a .me-1[self.state.is_some()] href={ "/item/" (self.id) } { (self.name) } - @if let Some(ref state) = self.state { - (state) - } - } - } -} - -pub struct ItemClassPreview { - pub id: Uuid, - pub name: String, -} - -impl ItemClassPreview { - pub fn new(id: Uuid, name: String) -> Self { - Self { id, name } - } -} - -impl Render for ItemClassPreview { - fn render(&self) -> Markup { - html! { - a href={ "/item-class/" (self.id) } { (self.name) } - } - } -} - pub enum PageActionMethod { Get, Post, @@ -298,77 +192,3 @@ pub fn parents_breadcrumb(name: ItemName, parents: &[ItemPreview], full: bool) - } } } - -// TODO: Is this module the right place for ItemState and ItemEvent? - -#[derive(Clone, Copy, Debug, sqlx::Type)] -#[sqlx(rename_all = "snake_case", type_name = "item_state")] -pub enum ItemState { - Borrowed, - Inactive, - Loaned, - Owned, -} - -impl ItemState { - pub fn colour(&self) -> Colour { - match self { - ItemState::Borrowed => Colour::Warning, - ItemState::Inactive => Colour::Secondary, - ItemState::Loaned => Colour::Danger, - ItemState::Owned => Colour::Primary, - } - } -} - -impl Render for ItemState { - fn render(&self) -> Markup { - html! { - span .badge.(self.colour().text_bg()) { - @match self { - ItemState::Borrowed => "borrowed", - ItemState::Inactive => "inactive", - ItemState::Loaned => "loaned", - ItemState::Owned => "owned", - } - } - } - } -} - -#[derive(Debug, Deserialize, Serialize, sqlx::Type)] -#[sqlx(rename_all = "snake_case", type_name = "item_event")] -#[serde(rename_all = "snake_case")] -pub enum ItemEvent { - Acquire, - Borrow, - Buy, - Dispose, - Gift, - Loan, - Lose, - RecieveGift, - ReturnBorrowed, - ReturnLoaned, - Sell, - Use, -} - -impl fmt::Display for ItemEvent { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ItemEvent::Acquire => write!(f, "acquire"), - ItemEvent::Borrow => write!(f, "borrow"), - ItemEvent::Buy => write!(f, "buy"), - ItemEvent::Dispose => write!(f, "dispose"), - ItemEvent::Gift => write!(f, "gift"), - ItemEvent::Loan => write!(f, "loan"), - ItemEvent::Lose => write!(f, "lose"), - ItemEvent::RecieveGift => write!(f, "recieve gift"), - ItemEvent::ReturnBorrowed => write!(f, "return borrowed"), - ItemEvent::ReturnLoaned => write!(f, "return loaned"), - ItemEvent::Sell => write!(f, "sell"), - ItemEvent::Use => write!(f, "use"), - } - } -} diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs index 5afa6ad..d63a2f5 100644 --- a/src/frontend/templates/mod.rs +++ b/src/frontend/templates/mod.rs @@ -5,6 +5,7 @@ pub mod datalist; pub mod forms; pub mod helpers; +mod render; use actix_identity::Identity; use maud::{html, Markup, Render, DOCTYPE}; diff --git a/src/frontend/templates/render.rs b/src/frontend/templates/render.rs new file mode 100644 index 0000000..9e10039 --- /dev/null +++ b/src/frontend/templates/render.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::{self, Display}; + +use maud::{html, Markup, Render}; + +use crate::database::items::ItemPreview; +use crate::database::{item_classes::ItemClassPreview, item_events::ItemEvent, items::ItemName}; + +impl Render for ItemClassPreview { + fn render(&self) -> Markup { + html! { + a href={ "/item-class/" (self.id) } { (self.name) } + } + } +} + +impl Display for ItemEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ItemEvent::Acquire => write!(f, "acquire"), + ItemEvent::Borrow => write!(f, "borrow"), + ItemEvent::Buy => write!(f, "buy"), + ItemEvent::Dispose => write!(f, "dispose"), + ItemEvent::Gift => write!(f, "gift"), + ItemEvent::Loan => write!(f, "loan"), + ItemEvent::Lose => write!(f, "lose"), + ItemEvent::RecieveGift => write!(f, "recieve gift"), + ItemEvent::ReturnBorrowed => write!(f, "return borrowed"), + ItemEvent::ReturnLoaned => write!(f, "return loaned"), + ItemEvent::Sell => write!(f, "sell"), + ItemEvent::Use => write!(f, "use"), + } + } +} + +impl Display for ItemName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Item(name) => write!(f, "{name}"), + Self::Class(name) => write!(f, "*{name}*"), + Self::None => write!(f, "[no name]"), + } + } +} + +impl Render for ItemName { + fn render(&self) -> Markup { + html! { + @match self { + Self::Item(name) => { (name) }, + Self::Class(name) => { em { (name) } }, + Self::None => { em { "[no name]" } }, + } + } + } +} + +impl Render for ItemPreview { + fn render(&self) -> Markup { + html! { + a .me-1[self.state.is_some()] href={ "/item/" (self.id) } { (self.name) } + @if let Some(ref state) = self.state { + (state) + } + } + } +} diff --git a/src/label/mod.rs b/src/label/mod.rs index 3aae2d7..7b22974 100644 --- a/src/label/mod.rs +++ b/src/label/mod.rs @@ -12,12 +12,13 @@ use barcode::{encode_code128, encode_data_matrix}; use pdf::{IndirectFontRef, PdfLayerReference}; use printpdf as pdf; use printpdf::{ImageTransform, Mm, PdfDocument, PdfDocumentReference, Pt, Px}; -use sqlx::{query_as, PgPool}; use thiserror::Error; use uuid::Uuid; pub use preset::LabelPreset; +use crate::database::ItemRepository; + const FONT: Cursor<&[u8]> = Cursor::new(include_bytes!( "../../assets/fonts/IosevkaLi7y-Regular.subset.ttf" )); @@ -32,6 +33,8 @@ pub enum Error { Io(#[from] std::io::Error), #[error("PDF error: {0}")] PrintPdf(#[from] printpdf::Error), + #[error("SQLx error: {0}")] + Sqlx(#[from] sqlx::Error), #[error("data is incomplete ({0} not given in data, but required in config)")] DataIncomplete(String), @@ -247,17 +250,13 @@ impl Label { Ok(doc.ok_or(Error::NoPages)?.save_to_bytes()?) } - pub async fn for_items(pool: &PgPool, ids: &[Uuid], config: LabelConfig) -> sqlx::Result { + pub async fn for_items( + item_repo: &ItemRepository, + ids: &[Uuid], + config: LabelConfig, + ) -> Result { Ok(Label { - pages: query_as!( - LabelPage, - r#"SELECT id AS "id?", to_char(short_id, '000000') AS "short_id?" - FROM items - WHERE id = ANY ($1)"#, - ids - ) - .fetch_all(pool) - .await?, + pages: item_repo.label_pages(ids).await?, config, }) } diff --git a/src/lib.rs b/src/lib.rs index 50b8e1f..1869540 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod config; +pub mod database; pub mod frontend; pub mod label; pub mod middleware; diff --git a/src/main.rs b/src/main.rs index 6477076..7347ff8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,10 @@ use actix_web::middleware::ErrorHandlers; use actix_web::{cookie::Key, http::StatusCode, web, App, HttpResponse, HttpServer}; use base64::prelude::{Engine as _, BASE64_STANDARD}; use clap::Parser; +use li7y::database::item_events::ItemEventRepository; +use li7y::database::item_states::ItemStateRepository; +use li7y::database::items::ItemRepository; +use li7y::database::ItemClassRepository; use log::{info, warn}; use mime_guess::from_path; use rust_embed::Embed; @@ -54,6 +58,10 @@ async fn main() -> std::io::Result<()> { App::new() .app_data(web::Data::new(config.clone())) .app_data(web::Data::new(pool.clone())) + .app_data(web::Data::new(ItemClassRepository::new(pool.clone()))) + .app_data(web::Data::new(ItemEventRepository::new(pool.clone()))) + .app_data(web::Data::new(ItemStateRepository::new(pool.clone()))) + .app_data(web::Data::new(ItemRepository::new(pool.clone()))) .service(web::scope("/static").route( "/{_:.*}", web::get().to(|path: web::Path| async {