diff --git a/.sqlx/query-02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55.json b/.sqlx/query-02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55.json deleted file mode 100644 index 69bc7e1..0000000 --- a/.sqlx/query-02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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\n WHERE items.original_packaging = $1", - "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": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - false - ] - }, - "hash": "02438c29c249ce2b446d15a41d07c40ce73946e62744a486cbbac256c4a4cf55" -} diff --git a/.sqlx/query-11f1edc0dce92fbd127159dbebd72b16de479d071f03433b480087bef99f5b1c.json b/.sqlx/query-11f1edc0dce92fbd127159dbebd72b16de479d071f03433b480087bef99f5b1c.json new file mode 100644 index 0000000..54ca168 --- /dev/null +++ b/.sqlx/query-11f1edc0dce92fbd127159dbebd72b16de479d071f03433b480087bef99f5b1c.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO item_events (item, date, event, description)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Date", + { + "Custom": { + "name": "item_event", + "kind": { + "Enum": [ + "acquire", + "borrow", + "buy", + "dispose", + "gift", + "loan", + "lose", + "recieve_gift", + "return_borrowed", + "return_loaned", + "sell", + "use" + ] + } + } + }, + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "11f1edc0dce92fbd127159dbebd72b16de479d071f03433b480087bef99f5b1c" +} diff --git a/.sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json b/.sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json new file mode 100644 index 0000000..490fb4b --- /dev/null +++ b/.sqlx/query-1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n event AS \"event: ItemEvent\",\n next AS \"next: ItemState\"\n FROM item_events_transitions\n WHERE state = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "event: ItemEvent", + "type_info": { + "Custom": { + "name": "item_event", + "kind": { + "Enum": [ + "acquire", + "borrow", + "buy", + "dispose", + "gift", + "loan", + "lose", + "recieve_gift", + "return_borrowed", + "return_loaned", + "sell", + "use" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "next: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1b860018f6cd1b18d28a87ba9da1f96fd7c8021c5e2ea652e6f6fe11a823c32c" +} diff --git a/.sqlx/query-3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171.json b/.sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json similarity index 71% rename from .sqlx/query-3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171.json rename to .sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json index 767fcf1..e93c7e3 100644 --- a/.sqlx/query-3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171.json +++ b/.sqlx/query-3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee.json @@ -1,6 +1,6 @@ { "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 FROM cte\n JOIN items\n ON cte.id = items.id\n JOIN item_classes\n ON items.class = item_classes.id\n ORDER BY items.created_at\n ", + "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": [ { @@ -37,6 +37,23 @@ "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": { @@ -49,8 +66,9 @@ false, null, null, - null + null, + true ] }, - "hash": "3dedb7b184103c1d418f7b94e26c75aea0c7d22e009299d1b87443e350578171" + "hash": "3fe94e76159c7911db688854710271e351ca34273dfdb21a7499f588715a91ee" } diff --git a/.sqlx/query-482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227.json b/.sqlx/query-482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227.json deleted file mode 100644 index a35d951..0000000 --- a/.sqlx/query-482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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\n WHERE items.class = $1", - "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": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - false - ] - }, - "hash": "482df01f0509cc8ec18ffe1caea8f65f11c170b67424e35697540ae12e577227" -} diff --git a/.sqlx/query-8683674fed595842f0369f2dafbc11c7f9f031931a5a4f8b43f3ffa1b8b6d10b.json b/.sqlx/query-8683674fed595842f0369f2dafbc11c7f9f031931a5a4f8b43f3ffa1b8b6d10b.json new file mode 100644 index 0000000..b13334c --- /dev/null +++ b/.sqlx/query-8683674fed595842f0369f2dafbc11c7f9f031931a5a4f8b43f3ffa1b8b6d10b.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n items.id,\n items.short_id,\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n op.name AS \"original_packaging_name?\",\n op_class.name AS \"original_packaging_class_name?\",\n op_state.state AS \"original_packaging_state: ItemState\",\n items.description,\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n LEFT JOIN items AS \"op\"\n ON items.original_packaging = op.id\n LEFT JOIN item_classes AS \"op_class\"\n ON op.class = op_class.id\n LEFT JOIN item_states AS \"op_state\"\n ON op.id = op_state.item\n WHERE items.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "short_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "class", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "class_name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "original_packaging", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "original_packaging_name?", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "original_packaging_class_name?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "original_packaging_state: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "state!: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + true, + false, + true, + false, + true + ] + }, + "hash": "8683674fed595842f0369f2dafbc11c7f9f031931a5a4f8b43f3ffa1b8b6d10b" +} diff --git a/.sqlx/query-915945504790c0295800c3e5dae1d38dba1c47e9be6cbf120313263336b8ca21.json b/.sqlx/query-915945504790c0295800c3e5dae1d38dba1c47e9be6cbf120313263336b8ca21.json new file mode 100644 index 0000000..7d19424 --- /dev/null +++ b/.sqlx/query-915945504790c0295800c3e5dae1d38dba1c47e9be6cbf120313263336b8ca21.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.parent = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "class_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "state!: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + true + ] + }, + "hash": "915945504790c0295800c3e5dae1d38dba1c47e9be6cbf120313263336b8ca21" +} diff --git a/.sqlx/query-9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043.json b/.sqlx/query-9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043.json deleted file mode 100644 index 893c114..0000000 --- a/.sqlx/query-9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n items.id,\n items.short_id,\n items.name,\n items.class,\n item_classes.name AS \"class_name\",\n items.original_packaging,\n op.name AS \"original_packaging_name?\",\n op_class.name AS \"original_packaging_class_name?\",\n items.description\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n LEFT JOIN items AS \"op\"\n ON items.original_packaging = op.id\n LEFT JOIN item_classes AS \"op_class\"\n ON op.class = op_class.id\n WHERE items.id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "short_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "class", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "class_name", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "original_packaging", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "original_packaging_name?", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "original_packaging_class_name?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "description", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - false, - false - ] - }, - "hash": "9c1a1e9c33539f6ec4800c9e8fc8ce3b65f49c34ad848321dc1b151c21cb0043" -} diff --git a/.sqlx/query-9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397.json b/.sqlx/query-9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397.json deleted file mode 100644 index 33de83f..0000000 --- a/.sqlx/query-9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "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\n WHERE items.parent = $1", - "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": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - false - ] - }, - "hash": "9e1704d0b60906061460e2c972fccf5d87b053857c161651360e5a8fdd80e397" -} diff --git a/.sqlx/query-a0ac133f1dce54853319b776ff6e7d3f186dc380e091858f85950719d73eee67.json b/.sqlx/query-a0ac133f1dce54853319b776ff6e7d3f186dc380e091858f85950719d73eee67.json new file mode 100644 index 0000000..b7a4314 --- /dev/null +++ b/.sqlx/query-a0ac133f1dce54853319b776ff6e7d3f186dc380e091858f85950719d73eee67.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.class = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "class_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "state!: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + true + ] + }, + "hash": "a0ac133f1dce54853319b776ff6e7d3f186dc380e091858f85950719d73eee67" +} diff --git a/.sqlx/query-cad9b7d82b518e4a88b62fa5ea335a2451af8fafeab425868d7196ed9f078874.json b/.sqlx/query-cad9b7d82b518e4a88b62fa5ea335a2451af8fafeab425868d7196ed9f078874.json new file mode 100644 index 0000000..5b29b2b --- /dev/null +++ b/.sqlx/query-cad9b7d82b518e4a88b62fa5ea335a2451af8fafeab425868d7196ed9f078874.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n items.id,\n items.name,\n item_classes.name AS \"class_name\",\n item_states.state AS \"state!: ItemState\"\n FROM items\n JOIN item_classes\n ON items.class = item_classes.id\n JOIN item_states\n ON items.id = item_states.item\n WHERE items.original_packaging = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "class_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "state!: ItemState", + "type_info": { + "Custom": { + "name": "item_state", + "kind": { + "Enum": [ + "borrowed", + "inactive", + "loaned", + "owned" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + false, + true + ] + }, + "hash": "cad9b7d82b518e4a88b62fa5ea335a2451af8fafeab425868d7196ed9f078874" +} diff --git a/.sqlx/query-ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a.json b/.sqlx/query-ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a.json new file mode 100644 index 0000000..3523ab1 --- /dev/null +++ b/.sqlx/query-ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM item_events WHERE id = $1 RETURNING item", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "item", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ce9d4ef7aff0cfbbace291d10e459771013e090463d7b858a26df1295e31184a" +} diff --git a/.sqlx/query-f700f5e4657cfb3d572e9f11c23d408c8f31c8d75737398429e256a167c91d68.json b/.sqlx/query-f700f5e4657cfb3d572e9f11c23d408c8f31c8d75737398429e256a167c91d68.json new file mode 100644 index 0000000..4367d79 --- /dev/null +++ b/.sqlx/query-f700f5e4657cfb3d572e9f11c23d408c8f31c8d75737398429e256a167c91d68.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, date, event AS \"event: ItemEvent\", description FROM item_events WHERE item = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "date", + "type_info": "Date" + }, + { + "ordinal": 2, + "name": "event: ItemEvent", + "type_info": { + "Custom": { + "name": "item_event", + "kind": { + "Enum": [ + "acquire", + "borrow", + "buy", + "dispose", + "gift", + "loan", + "lose", + "recieve_gift", + "return_borrowed", + "return_loaned", + "sell", + "use" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f700f5e4657cfb3d572e9f11c23d408c8f31c8d75737398429e256a167c91d68" +} diff --git a/Cargo.toml b/Cargo.toml index b2ed5d6..a3e63ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ serde_urlencoded = "0.7.1" serde_variant = "0.1.3" sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] } thiserror = "1.0.61" -time = { version = "0.3.36", features = ["serde"] } +time = { version = "0.3.36", features = ["parsing", "serde"] } uuid = { version = "1.9.0", features = ["serde", "v4"] } [profile.dev.package.sqlx-macros] diff --git a/flake.nix b/flake.nix index 6f63fc2..4fac902 100644 --- a/flake.nix +++ b/flake.nix @@ -105,6 +105,7 @@ cargo-deny cargo-watch clippy + graphviz postgresql.lib postgresql reuse diff --git a/migrations/20240721221728_add_item_state.dot b/migrations/20240721221728_add_item_state.dot new file mode 100644 index 0000000..1fc0d75 --- /dev/null +++ b/migrations/20240721221728_add_item_state.dot @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +digraph item_status { + rankdir=LR; + node [shape=point]; q0; + node [shape=circle]; owned loaned borrowed inactive; + q0 -> inactive; + // s/\(.*\) -> \(.*\) \[label="\(.*\)"\];/('\1', '\3', '\2'),/ + inactive -> owned [label="buy"]; + inactive -> owned [label="recieve_gift"]; + inactive -> owned [label="acquire"]; // generic, also used as fallback for older items + inactive -> borrowed [label="borrow"]; + owned -> inactive [label="sell"]; + owned -> inactive [label="gift"]; + owned -> inactive [label="lose"]; + owned -> inactive [label="use"]; + owned -> inactive [label="dispose"]; + owned -> loaned [label="loan"]; + loaned -> owned [label="return_loaned"]; + borrowed -> inactive [label="return_borrowed"]; +} diff --git a/migrations/20240721221728_add_item_state.down.sql b/migrations/20240721221728_add_item_state.down.sql new file mode 100644 index 0000000..f1c057d --- /dev/null +++ b/migrations/20240721221728_add_item_state.down.sql @@ -0,0 +1,20 @@ +-- SPDX-FileCopyrightText: 2024 Simon Bruder +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +DROP TRIGGER add_default_item_event ON items; +DROP FUNCTION add_default_item_event; + +DROP VIEW item_states; + +DROP TRIGGER check_item_events_delete ON item_events; +DROP FUNCTION check_item_events_delete; +DROP TRIGGER check_item_events_fsm ON item_events; +DROP FUNCTION check_item_events_fsm; +DROP TABLE item_events; + +DROP AGGREGATE item_events_fsm(item_event); +DROP FUNCTION item_events_transition; +DROP TABLE item_events_transitions; +DROP TYPE item_state; +DROP TYPE item_event; diff --git a/migrations/20240721221728_add_item_state.up.sql b/migrations/20240721221728_add_item_state.up.sql new file mode 100644 index 0000000..ee3ab14 --- /dev/null +++ b/migrations/20240721221728_add_item_state.up.sql @@ -0,0 +1,155 @@ +-- SPDX-FileCopyrightText: 2024 Simon Bruder +-- +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- This datbase design is inspired by the following two blog posts by Felix Geisendörfer and Raphael Medaer: +-- +-- https://felixge.de/2017/07/27/implementing-state-machines-in-postgresql/ +-- https://raphael.medaer.me/2019/06/12/pgfsm.html +-- +-- I chose a design that makes a compromise between both designs. +-- To simplify modifying transitions, it uses a table for storing the transitions, +-- but it does not version it, as the design should not radically change. + +CREATE TYPE item_state AS ENUM ( + 'borrowed', + 'inactive', + 'loaned', + 'owned' +); + +CREATE TYPE item_event AS ENUM ( + 'acquire', + 'borrow', + 'buy', + 'dispose', + 'gift', + 'loan', + 'lose', + 'recieve_gift', + 'return_borrowed', + 'return_loaned', + 'sell', + 'use' +); + +CREATE TABLE item_events_transitions ( + state item_state, + event item_event, + next item_state, + PRIMARY KEY (state, event, next) +); + +INSERT INTO item_events_transitions VALUES + ('inactive', 'buy', 'owned'), + ('inactive', 'recieve_gift', 'owned'), + ('inactive', 'acquire', 'owned'), + ('inactive', 'borrow', 'borrowed'), + ('owned', 'sell', 'inactive'), + ('owned', 'gift', 'inactive'), + ('owned', 'lose', 'inactive'), + ('owned', 'use', 'inactive'), + ('owned', 'dispose', 'inactive'), + ('owned', 'loan', 'loaned'), + ('loaned', 'return_loaned', 'owned'), + ('borrowed', 'return_borrowed', 'inactive'); + +CREATE FUNCTION item_events_transition(_state item_state, _event item_event) +RETURNS item_state AS $$ +SELECT next + FROM item_events_transitions + WHERE state = _state AND event = _event; +$$ LANGUAGE sql STRICT; + +CREATE AGGREGATE item_events_fsm(item_event) ( + SFUNC = item_events_transition, + STYPE = item_state, + INITCOND = 'inactive' +); + +CREATE TABLE item_events ( + id SERIAL PRIMARY KEY, + item UUID NOT NULL REFERENCES items(id), + date DATE NOT NULL DEFAULT now(), + event item_event NOT NULL, + description VARCHAR NOT NULL DEFAULT '' +); + +CREATE FUNCTION check_item_events_fsm() +RETURNS trigger AS $$ +BEGIN + IF (SELECT item_events_fsm(event ORDER BY id) FROM ( + SELECT id, event FROM item_events WHERE item = NEW.item + UNION ALL + SELECT NEW.id, NEW.event + ) events) IS NULL THEN + RAISE EXCEPTION 'Event not possible from current state'; + END IF; + + RETURN NEW; +END +$$ LANGUAGE plpgsql; + +CREATE TRIGGER check_item_events_fsm + BEFORE INSERT ON item_events + FOR EACH ROW + EXECUTE PROCEDURE check_item_events_fsm(); + +CREATE FUNCTION check_item_events_delete() +RETURNS trigger AS $$ +BEGIN + IF (SELECT OLD.id <> max(id) FROM item_events WHERE item = OLD.item) THEN + RAISE EXCEPTION 'Only the last event of an item can be deleted'; + END IF; + + RETURN OLD; +END +$$ LANGUAGE plpgsql; + +CREATE TRIGGER check_item_events_delete + BEFORE DELETE ON item_events + FOR EACH ROW + EXECUTE PROCEDURE check_item_events_delete(); + +CREATE VIEW item_states AS + -- probably not the best query, but it works + SELECT + items.id AS "item", + state, + event AS "last_event", + date AS "last_event_date", + item_events.description AS "last_event_description" + FROM item_events + -- items without eny event must be included + RIGHT JOIN items + ON item_events.item = items.id + JOIN ( + SELECT + item_events_fsm(event ORDER BY item_events.id) AS "state", + max(item_events.id) AS "id", + items.id AS "item" + FROM item_events + -- see above + RIGHT JOIN items + ON item_events.item = items.id + GROUP BY item_events.item, items.id + ) last_event + ON items.id = last_event.item AND (item_events.id = last_event.id OR last_event.id IS NULL); + +CREATE FUNCTION add_default_item_event() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO item_events (item, event, description) + VALUES (NEW.id, 'acquire', 'automatically added on item insert'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER add_default_item_event + AFTER INSERT ON items + FOR EACH ROW + EXECUTE FUNCTION add_default_item_event(); + +INSERT INTO item_events (item, event, description) + SELECT id, 'acquire', 'automatically added on migration' FROM items; diff --git a/src/frontend/item/event.rs b/src/frontend/item/event.rs new file mode 100644 index 0000000..ffcc2e6 --- /dev/null +++ b/src/frontend/item/event.rs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(delete).service(add); +} + +// 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, + 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()) + .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, + path: web::Path, + 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)?; + + Ok(web::Redirect::to(format!("/item/{}", id)).see_other()) +} diff --git a/src/frontend/item/list.rs b/src/frontend/item/list.rs index 0af11a1..bcc067b 100644 --- a/src/frontend/item/list.rs +++ b/src/frontend/item/list.rs @@ -10,7 +10,9 @@ use uuid::Uuid; use crate::frontend::templates::{ self, - helpers::{Colour, ItemName, ItemPreview, PageAction, PageActionGroup, PageActionMethod}, + helpers::{ + Colour, ItemName, ItemPreview, ItemState, PageAction, PageActionGroup, PageActionMethod, + }, TemplateConfig, }; @@ -24,6 +26,7 @@ struct ItemListEntry { class: Uuid, class_name: String, parents: Vec, + state: ItemState, } #[get("/items")] @@ -61,12 +64,15 @@ async fn get(pool: web::Data, user: Identity) -> actix_web::Result>", - cte.parent_class_names AS "parent_class_names!" + 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 "# ) @@ -78,6 +84,7 @@ async fn get(pool: web::Data, user: Identity) -> actix_web::Result, user: Identity) -> actix_web::Result, user: Identity) -> actix_web::Result, description: String, + state: ItemState, +} + +struct ItemEventDetails { + id: i32, + date: Date, + event: ItemEvent, + description: String, } #[get("/item/{id}")] @@ -46,14 +59,20 @@ async fn get( items.original_packaging, op.name AS "original_packaging_name?", op_class.name AS "original_packaging_class_name?", - items.description + 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 ) @@ -69,13 +88,37 @@ async fn get( 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 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 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 parents = query!( r#"SELECT items.id, items.name, item_classes.name AS "class_name" FROM items @@ -93,27 +136,43 @@ async fn get( .map_err(error::ErrorInternalServerError)?; let children = query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" + 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)) + .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 = query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" + 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)) + .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)?; @@ -126,6 +185,7 @@ async fn get( path: &format!("/item/{}", item.id), title: Some(&title), page_title: Some(Box::new(item.name.clone())), + page_title_extra: Some(Box::new(item.state)), page_actions: vec![ (PageActionGroup::Button { action: PageAction { @@ -157,40 +217,122 @@ async fn get( ..Default::default() }, html! { - table .table { - tr { - th { "UUID" } - td { (item.id) } - } - tr { - th { "Short ID" } - td { (item.short_id) } - } - tr { - th { "Name" } - td { (item.name.clone().terse()) } - } - tr { - th { "Class" } - td { a href={ "/item-class/" (item.class) } { (item.class_name) } } - } - tr { - th { "Parents" } - td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) } - } - tr { - th { "Original Packaging" } - td { - @if let Some(original_packaging) = item.original_packaging { - (original_packaging) - } @else { - "-" + div .row { + div .col-md-8 { + table .table { + tr { + th { "UUID" } + td { (item.id) } + } + tr { + th { "Short ID" } + td { (item.short_id) } + } + tr { + th { "Name" } + td { (item.name.clone().terse()) } + } + tr { + th { "Class" } + td { a href={ "/item-class/" (item.class) } { (item.class_name) } } + } + tr { + th { "Parents" } + td { (templates::helpers::parents_breadcrumb(item.name, &parents, true)) } + } + tr { + th { "Original Packaging" } + td { + @if let Some(original_packaging) = item.original_packaging { + (original_packaging) + } @else { + "-" + } + } + } + tr { + th { "Description" } + td style="white-space: pre-wrap" { (item.description) } } } } - tr { - th { "Description" } - td style="white-space: pre-wrap" { (item.description) } + div .col-md-4 { + div .card { + div .card-header { + "Events" + } + ul .list-group.list-group-flush { + @for (idx, event) in events.iter().enumerate() { + li .list-group-item { + div .d-flex.justify-content-between.align-items-start.mb-2[!event.description.is_empty()] { + strong { (event.event) } + span .badge.text-bg-secondary { (event.date) } + } + @if idx + 1 == events.len() { + form .float-end action={ "/items/event/" (event.id) "/delete" } method="POST" { + button .btn.text-bg-danger.btn-sm type="submit" { "Delete" } + } + } + @if !event.description.is_empty() { + p .mb-0 { (event.description) } + } + } + } + @if events.is_empty() { + li .list-group-item.text-secondary { "no events" } + } + } + div .card-body { + div .d-flex.gap-1.flex-wrap { + @for (event, next) in &possible_events { + button + .btn.(next.colour().button()) + type="button" + data-bs-toggle="modal" + data-bs-target="#add-event-modal" + data-event-type=(to_variant_name(event).unwrap()) + { (event) } + } + } + } + } + } + div .modal #add-event-modal tabindex="-1" { + div .modal-dialog { + form .modal-content action={ "/item/" (id) "/events/add" } method="POST" { + div .modal-header { + h5 .modal-title { "Add event" } + button .btn-close type="button" data-bs-dismiss="modal"; + } + div .modal-body { + div .mb-3 { + select .form-select name="event" id="event" { + @for (event, _) in &possible_events { + option value=(to_variant_name(event).unwrap()) { (event) } + } + } + } + (forms::InputGroup { + r#type: forms::InputType::Text, + name: "description", + title: "Description", + ..Default::default() + }) + (forms::InputGroup { + r#type: forms::InputType::Date, + name: "date", + title: "Date", + value: Some(&OffsetDateTime::now_utc().date()), + required: true, + ..Default::default() + }) + } + div .modal-footer { + button .btn.btn-secondary type="button" data-bs-dismiss="modal" { "Cancel" } + button .btn.btn-primary type="submit" { "Add" } + } + } + } } } diff --git a/src/frontend/item_class/show.rs b/src/frontend/item_class/show.rs index 254aa5c..3423b9f 100644 --- a/src/frontend/item_class/show.rs +++ b/src/frontend/item_class/show.rs @@ -11,7 +11,8 @@ use uuid::Uuid; use crate::frontend::templates::{ self, helpers::{ - Colour, ItemClassPreview, ItemPreview, PageAction, PageActionGroup, PageActionMethod, + Colour, ItemClassPreview, ItemPreview, ItemState, PageAction, PageActionGroup, + PageActionMethod, }, TemplateConfig, }; @@ -70,14 +71,22 @@ async fn get( .map_err(error::ErrorInternalServerError)?; let items = query!( - r#"SELECT items.id, items.name, item_classes.name AS "class_name" + 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)) + .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)?; diff --git a/src/frontend/templates/forms.rs b/src/frontend/templates/forms.rs index 5c863cc..f86be2d 100644 --- a/src/frontend/templates/forms.rs +++ b/src/frontend/templates/forms.rs @@ -10,6 +10,7 @@ use super::datalist::Datalist; #[derive(Clone)] pub enum InputType { + Date, Text, Textarea, } @@ -17,6 +18,7 @@ pub enum InputType { impl Display for InputType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Date => write!(f, "date"), Self::Text => write!(f, "text"), Self::Textarea => write!(f, "textarea"), } diff --git a/src/frontend/templates/helpers.rs b/src/frontend/templates/helpers.rs index 5462aeb..cc65d62 100644 --- a/src/frontend/templates/helpers.rs +++ b/src/frontend/templates/helpers.rs @@ -6,6 +6,7 @@ use std::fmt::{self, Display}; use crate::label::LabelPreset; use maud::{html, Markup, PreEscaped, Render}; +use serde::{Deserialize, Serialize}; use uuid::Uuid; pub enum Css<'a> { @@ -78,9 +79,13 @@ impl fmt::Display for Colour { } impl Colour { - fn button(&self) -> String { + pub fn button(&self) -> String { format!("btn-{self}") } + + pub fn text_bg(&self) -> String { + format!("text-bg-{self}") + } } #[derive(Clone)] @@ -134,25 +139,39 @@ impl Render for ItemName { 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 } + 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 href={ "/item/" (self.id) } { (self.name) } + a .me-1[self.state.is_some()] href={ "/item/" (self.id) } { (self.name) } + @if let Some(ref state) = self.state { + (state) + } } } } @@ -279,3 +298,77 @@ 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 6fc40d9..542f42d 100644 --- a/src/frontend/templates/mod.rs +++ b/src/frontend/templates/mod.rs @@ -68,6 +68,7 @@ pub struct TemplateConfig<'a> { pub path: &'a str, pub title: Option<&'a str>, pub page_title: Option>, + pub page_title_extra: Option>, pub page_actions: Vec, pub extra_css: Vec>, pub extra_js: Vec>, @@ -81,6 +82,7 @@ impl Default for TemplateConfig<'_> { path: "/", title: None, page_title: None, + page_title_extra: None, page_actions: Vec::new(), extra_css: Vec::new(), extra_js: Vec::new(), @@ -116,12 +118,15 @@ pub fn base(config: TemplateConfig, content: Markup) -> Markup { main .container.my-4 { div .d-flex.justify-content-between.mb-3 { - div { + div .d-flex.align-items-center.gap-1 { @if let Some(ref page_title) = config.page_title { h2 { (page_title) } } + @if let Some(ref page_title_extra) = config.page_title_extra { + (page_title_extra) + } } div .d-flex.h-100.gap-1 { @for page_action in config.page_actions { diff --git a/static/app.js b/static/app.js index 9e42f24..9fcc20a 100644 --- a/static/app.js +++ b/static/app.js @@ -85,4 +85,8 @@ .join(",") }) } + + document.getElementById("add-event-modal").addEventListener("show.bs.modal", e => { + document.getElementById("event").value = e.relatedTarget.dataset.eventType + }) })()