diff --git a/.sqlx/query-2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160.json b/.sqlx/query-2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160.json new file mode 100644 index 0000000..9456b7b --- /dev/null +++ b/.sqlx/query-2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM tag_cooccurrence", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160" +} diff --git a/.sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json b/.sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json new file mode 100644 index 0000000..4b4ef2e --- /dev/null +++ b/.sqlx/query-2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE tags\n SET slug = $2, name = $3\n WHERE id = $1\n RETURNING id, slug, name, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "2f868a87ff4c32270ca92447e230e6a875b81907877498329c3664dc41cc7a08" +} diff --git a/.sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json b/.sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json new file mode 100644 index 0000000..8f363a3 --- /dev/null +++ b/.sqlx/query-300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, name, created_at\n FROM tags\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "300dd247ebbca159f76da9e506c99e838bf7bd5114f645facb573f0df3f80807" +} diff --git a/.sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json b/.sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json new file mode 100644 index 0000000..b835d9b --- /dev/null +++ b/.sqlx/query-31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id, t.slug, t.name, t.created_at\n FROM tags t\n JOIN project_tags pt ON t.id = pt.tag_id\n WHERE pt.project_id = $1\n ORDER BY t.name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "31c6f77f585132105fd8da6aad396337fdd24165794cadba6a2aa4fa3663dc28" +} diff --git a/.sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json b/.sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json new file mode 100644 index 0000000..48b2ad1 --- /dev/null +++ b/.sqlx/query-4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name, \n t.created_at,\n COUNT(pt.project_id)::int as \"project_count!\"\n FROM tags t\n LEFT JOIN project_tags pt ON t.id = pt.tag_id\n GROUP BY t.id, t.slug, t.name, t.created_at\n ORDER BY t.name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "project_count!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + null + ] + }, + "hash": "4ce09c53c094bc94294cfd14e4a696b4cc118bf950b287132718fb02ec208879" +} diff --git a/.sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json b/.sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json new file mode 100644 index 0000000..a45df42 --- /dev/null +++ b/.sqlx/query-5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n t.id, \n t.slug, \n t.name, \n t.created_at,\n tc.count\n FROM tag_cooccurrence tc\n JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)\n WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1\n ORDER BY tc.count DESC, t.name ASC\n LIMIT $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "count", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "5564b47c730abd5436ee2836aa684054e709357b1993529f3b12a1597d0a1b32" +} diff --git a/.sqlx/query-5f26572b23588a5a96cb027b5517d75eb8a12c4d6123bf72b966de5662f4b2d4.json b/.sqlx/query-5f26572b23588a5a96cb027b5517d75eb8a12c4d6123bf72b966de5662f4b2d4.json new file mode 100644 index 0000000..924edac --- /dev/null +++ b/.sqlx/query-5f26572b23588a5a96cb027b5517d75eb8a12c4d6123bf72b966de5662f4b2d4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5f26572b23588a5a96cb027b5517d75eb8a12c4d6123bf72b966de5662f4b2d4" +} diff --git a/.sqlx/query-778619a2d55b7e311f9d8bfd75335a1605065f136bf350b030d1b86d3cc92b37.json b/.sqlx/query-778619a2d55b7e311f9d8bfd75335a1605065f136bf350b030d1b86d3cc92b37.json new file mode 100644 index 0000000..fd1037a --- /dev/null +++ b/.sqlx/query-778619a2d55b7e311f9d8bfd75335a1605065f136bf350b030d1b86d3cc92b37.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM tags WHERE LOWER(name) = LOWER($1)) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "778619a2d55b7e311f9d8bfd75335a1605065f136bf350b030d1b86d3cc92b37" +} diff --git a/.sqlx/query-7f264699f8897371b3f1a36f7908a1652a256537b66fe9a4452be82afcb0d7bf.json b/.sqlx/query-7f264699f8897371b3f1a36f7908a1652a256537b66fe9a4452be82afcb0d7bf.json new file mode 100644 index 0000000..ab47749 --- /dev/null +++ b/.sqlx/query-7f264699f8897371b3f1a36f7908a1652a256537b66fe9a4452be82afcb0d7bf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM projects WHERE slug = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7f264699f8897371b3f1a36f7908a1652a256537b66fe9a4452be82afcb0d7bf" +} diff --git a/.sqlx/query-7f285ead462f3247e5c2e99f45188b6d3e8d5376776a9b6e815b1a6e223d55cf.json b/.sqlx/query-7f285ead462f3247e5c2e99f45188b6d3e8d5376776a9b6e815b1a6e223d55cf.json new file mode 100644 index 0000000..678a06c --- /dev/null +++ b/.sqlx/query-7f285ead462f3247e5c2e99f45188b6d3e8d5376776a9b6e815b1a6e223d55cf.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_tags (project_id, tag_id)\n VALUES ($1, $2)\n ON CONFLICT (project_id, tag_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7f285ead462f3247e5c2e99f45188b6d3e8d5376776a9b6e815b1a6e223d55cf" +} diff --git a/.sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json b/.sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json new file mode 100644 index 0000000..1f4fc5a --- /dev/null +++ b/.sqlx/query-7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO tags (slug, name)\n VALUES ($1, $2)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7f51a4655f0c523554b1e57fd70cfd7d8930909c64495b2ae1c1f44f905ce44e" +} diff --git a/.sqlx/query-a55f7d64fc0f41f150663aae82ac6fbcabf90d2627d194c3fb23f80c24dcc57a.json b/.sqlx/query-a55f7d64fc0f41f150663aae82ac6fbcabf90d2627d194c3fb23f80c24dcc57a.json new file mode 100644 index 0000000..6ad3e6e --- /dev/null +++ b/.sqlx/query-a55f7d64fc0f41f150663aae82ac6fbcabf90d2627d194c3fb23f80c24dcc57a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM tags WHERE slug = $1) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a55f7d64fc0f41f150663aae82ac6fbcabf90d2627d194c3fb23f80c24dcc57a" +} diff --git a/.sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json b/.sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json new file mode 100644 index 0000000..6d1849b --- /dev/null +++ b/.sqlx/query-baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, name, created_at\n FROM tags\n WHERE slug = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "baffa1674306622fbc4cb3cae9e351517f4d595c11364ec797666bab05444120" +} diff --git a/.sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json b/.sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json new file mode 100644 index 0000000..ad5c45d --- /dev/null +++ b/.sqlx/query-c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT \n p.id, \n p.slug, \n p.title, \n p.description, \n p.status as \"status: ProjectStatus\", \n p.github_repo, \n p.demo_url, \n p.priority, \n p.icon, \n p.last_github_activity, \n p.created_at, \n p.updated_at\n FROM projects p\n JOIN project_tags pt ON p.id = pt.project_id\n WHERE pt.tag_id = $1\n ORDER BY p.priority DESC, p.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "status: ProjectStatus", + "type_info": { + "Custom": { + "name": "project_status", + "kind": { + "Enum": [ + "active", + "maintained", + "archived", + "hidden" + ] + } + } + } + }, + { + "ordinal": 5, + "name": "github_repo", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "demo_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "icon", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "last_github_activity", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + false, + false + ] + }, + "hash": "c7dd8cd6c4e50e1e0f327c040a9497d8427848e8754778fbaf5f22e656e04e12" +} diff --git a/.sqlx/query-d449cad77fa2e65e2f4d1eec62daa09045e903648e615083500f0f373c4f3525.json b/.sqlx/query-d449cad77fa2e65e2f4d1eec62daa09045e903648e615083500f0f373c4f3525.json new file mode 100644 index 0000000..f338bee --- /dev/null +++ b/.sqlx/query-d449cad77fa2e65e2f4d1eec62daa09045e903648e615083500f0f373c4f3525.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM project_tags WHERE project_id = $1 AND tag_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d449cad77fa2e65e2f4d1eec62daa09045e903648e615083500f0f373c4f3525" +} diff --git a/.sqlx/query-d988a5efcea128fd29c9476b5ea995116e81b7f2b86dd39e33a90063130c4650.json b/.sqlx/query-d988a5efcea128fd29c9476b5ea995116e81b7f2b86dd39e33a90063130c4650.json new file mode 100644 index 0000000..630c8b8 --- /dev/null +++ b/.sqlx/query-d988a5efcea128fd29c9476b5ea995116e81b7f2b86dd39e33a90063130c4650.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO tag_cooccurrence (tag_a, tag_b, count)\n SELECT \n LEAST(t1.tag_id, t2.tag_id) as tag_a,\n GREATEST(t1.tag_id, t2.tag_id) as tag_b,\n COUNT(*)::int as count\n FROM project_tags t1\n JOIN project_tags t2 ON t1.project_id = t2.project_id\n WHERE t1.tag_id < t2.tag_id\n GROUP BY tag_a, tag_b\n HAVING COUNT(*) > 0\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "d988a5efcea128fd29c9476b5ea995116e81b7f2b86dd39e33a90063130c4650" +} diff --git a/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json b/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json new file mode 100644 index 0000000..4539a5e --- /dev/null +++ b/.sqlx/query-dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM tags WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824" +} diff --git a/.sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json b/.sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json new file mode 100644 index 0000000..85ba3cc --- /dev/null +++ b/.sqlx/query-de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO tags (slug, name)\n VALUES ($1, $2)\n RETURNING id, slug, name, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "de9ba22c6a50b35761097abc410d22a6c5e6c307bca8d7ba8d2aa5a08c4ce91f" +} diff --git a/.sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json b/.sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json new file mode 100644 index 0000000..b3ac759 --- /dev/null +++ b/.sqlx/query-df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, name, created_at\n FROM tags\n ORDER BY name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "df4b8594ebbe7c0cfdfaa03a954931d50e87bb52bc4131c0b4803a57688d19ff" +} diff --git a/Justfile b/Justfile index 3ed8372..db83ae2 100644 --- a/Justfile +++ b/Justfile @@ -88,9 +88,13 @@ docker-run port="8080": just docker-run-json {{port}} | hl --config .hl.config.toml -P docker-run-json port="8080": + #!/usr/bin/env bash + set -euo pipefail docker stop xevion-dev-container 2>/dev/null || true docker rm xevion-dev-container 2>/dev/null || true - docker run --name xevion-dev-container -p {{port}}:8080 xevion-dev + # Replace localhost with host.docker.internal for Docker networking + DOCKER_DATABASE_URL="${DATABASE_URL//localhost/host.docker.internal}" + docker run --name xevion-dev-container -p {{port}}:8080 --env-file .env -e DATABASE_URL="$DOCKER_DATABASE_URL" xevion-dev [script("bun")] seed: @@ -159,8 +163,8 @@ db cmd="start": } else if (CMD === "reset") { if (!container) create(); else { - run(["exec", NAME, "psql", "-U", USER, "-c", `DROP DATABASE IF EXISTS ${DB}`]); - run(["exec", NAME, "psql", "-U", USER, "-c", `CREATE DATABASE ${DB}`]); + run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `DROP DATABASE IF EXISTS ${DB}`]); + run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-c", `CREATE DATABASE ${DB}`]); console.log("✅ reset"); } await updateEnv(); diff --git a/migrations/20260106073535_initial_schema.sql b/migrations/20260106000000_initial_schema.sql similarity index 100% rename from migrations/20260106073535_initial_schema.sql rename to migrations/20260106000000_initial_schema.sql diff --git a/migrations/20260106020134_add_tags_system.sql b/migrations/20260106020134_add_tags_system.sql new file mode 100644 index 0000000..33ac1aa --- /dev/null +++ b/migrations/20260106020134_add_tags_system.sql @@ -0,0 +1,36 @@ +-- Tags table +CREATE TABLE tags ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Indexes for tags +CREATE INDEX idx_tags_slug ON tags(slug); + +-- Case-insensitive unique constraint on name +CREATE UNIQUE INDEX idx_tags_name_lower ON tags(LOWER(name)); + +-- Project-Tag junction table +CREATE TABLE project_tags ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (project_id, tag_id) +); + +-- Indexes for project_tags +CREATE INDEX idx_project_tags_project_id ON project_tags(project_id); +CREATE INDEX idx_project_tags_tag_id ON project_tags(tag_id); + +-- Tag cooccurrence matrix +CREATE TABLE tag_cooccurrence ( + tag_a UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + tag_b UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (tag_a, tag_b), + CHECK (tag_a < tag_b) +); + +-- Index for reverse lookups +CREATE INDEX idx_tag_cooccurrence_tag_b ON tag_cooccurrence(tag_b, tag_a); diff --git a/src/bin/seed.rs b/src/bin/seed.rs index ecf45ef..b26b8e1 100644 --- a/src/bin/seed.rs +++ b/src/bin/seed.rs @@ -99,5 +99,115 @@ async fn main() -> Result<(), Box> { println!("✅ Seeded {} projects", project_count); + // Seed tags + let tags = vec![ + ("rust", "Rust"), + ("python", "Python"), + ("typescript", "TypeScript"), + ("javascript", "JavaScript"), + ("web", "Web"), + ("cli", "CLI"), + ("library", "Library"), + ("game", "Game"), + ("data-structures", "Data Structures"), + ("algorithms", "Algorithms"), + ("multiplayer", "Multiplayer"), + ("config", "Config"), + ]; + + let mut tag_ids = std::collections::HashMap::new(); + + for (slug, name) in tags { + let result = sqlx::query!( + r#" + INSERT INTO tags (slug, name) + VALUES ($1, $2) + RETURNING id + "#, + slug, + name + ) + .fetch_one(&pool) + .await?; + + tag_ids.insert(slug, result.id); + } + + println!("✅ Seeded {} tags", tag_ids.len()); + + // Associate tags with projects + let project_tag_associations = vec![ + // xevion-dev + ("xevion-dev", vec!["rust", "web", "typescript"]), + // Contest + ( + "contest", + vec!["python", "web", "algorithms", "data-structures"], + ), + // Reforge + ("reforge", vec!["rust", "library", "game"]), + // Algorithms + ( + "algorithms", + vec!["python", "algorithms", "data-structures"], + ), + // WordPlay + ( + "wordplay", + vec!["typescript", "javascript", "web", "game", "multiplayer"], + ), + // Dotfiles + ("dotfiles", vec!["config", "cli"]), + ]; + + let mut association_count = 0; + + for (project_slug, tag_slugs) in project_tag_associations { + let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug) + .fetch_one(&pool) + .await? + .id; + + for tag_slug in tag_slugs { + if let Some(&tag_id) = tag_ids.get(tag_slug) { + sqlx::query!( + "INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)", + project_id, + tag_id + ) + .execute(&pool) + .await?; + + association_count += 1; + } + } + } + + println!("✅ Created {} project-tag associations", association_count); + + // Recalculate tag cooccurrence + sqlx::query!("DELETE FROM tag_cooccurrence") + .execute(&pool) + .await?; + + sqlx::query!( + r#" + INSERT INTO tag_cooccurrence (tag_a, tag_b, count) + SELECT + LEAST(t1.tag_id, t2.tag_id) as tag_a, + GREATEST(t1.tag_id, t2.tag_id) as tag_b, + COUNT(*)::int as count + FROM project_tags t1 + JOIN project_tags t2 ON t1.project_id = t2.project_id + WHERE t1.tag_id < t2.tag_id + GROUP BY tag_a, tag_b + HAVING COUNT(*) > 0 + "# + ) + .execute(&pool) + .await?; + + println!("✅ Recalculated tag cooccurrence"); + Ok(()) } diff --git a/src/db.rs b/src/db.rs index 56eec49..36d1dbc 100644 --- a/src/db.rs +++ b/src/db.rs @@ -31,6 +31,28 @@ pub struct DbProject { pub updated_at: OffsetDateTime, } +// Tag database models +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct DbTag { + pub id: Uuid, + pub slug: String, + pub name: String, + pub created_at: OffsetDateTime, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct DbProjectTag { + pub project_id: Uuid, + pub tag_id: Uuid, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct DbTagCooccurrence { + pub tag_a: Uuid, + pub tag_b: Uuid, + pub count: i32, +} + // API response types #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiProjectLink { @@ -42,6 +64,7 @@ pub struct ApiProjectLink { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiProject { pub id: String, + pub slug: String, pub name: String, #[serde(rename = "shortDescription")] pub short_description: String, @@ -50,6 +73,45 @@ pub struct ApiProject { pub links: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTag { + pub id: String, + pub slug: String, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiProjectWithTags { + #[serde(flatten)] + pub project: ApiProject, + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTagWithCount { + #[serde(flatten)] + pub tag: ApiTag, + pub project_count: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiRelatedTag { + #[serde(flatten)] + pub tag: ApiTag, + pub cooccurrence_count: i32, +} + +impl DbTag { + /// Convert database tag to API response format + pub fn to_api_tag(&self) -> ApiTag { + ApiTag { + id: self.id.to_string(), + slug: self.slug.clone(), + name: self.name.clone(), + } + } +} + impl DbProject { /// Convert database project to API response format pub fn to_api_project(&self) -> ApiProject { @@ -71,6 +133,7 @@ impl DbProject { ApiProject { id: self.id.to_string(), + slug: self.slug.clone(), name: self.title.clone(), short_description: self.description.clone(), icon: self.icon.clone(), @@ -121,3 +184,334 @@ pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> { .await .map(|_| ()) } + +// Helper function: slugify text +pub fn slugify(text: &str) -> String { + text.to_lowercase() + .chars() + .map(|c| { + if c.is_alphanumeric() { + c + } else if c.is_whitespace() || c == '-' || c == '_' { + '-' + } else { + '\0' + } + }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +// Tag CRUD queries + +pub async fn create_tag( + pool: &PgPool, + name: &str, + slug_override: Option<&str>, +) -> Result { + let slug = slug_override + .map(|s| slugify(s)) + .unwrap_or_else(|| slugify(name)); + + sqlx::query_as!( + DbTag, + r#" + INSERT INTO tags (slug, name) + VALUES ($1, $2) + RETURNING id, slug, name, created_at + "#, + slug, + name + ) + .fetch_one(pool) + .await +} + +pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + DbTag, + r#" + SELECT id, slug, name, created_at + FROM tags + WHERE id = $1 + "#, + id + ) + .fetch_optional(pool) + .await +} + +pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as!( + DbTag, + r#" + SELECT id, slug, name, created_at + FROM tags + WHERE slug = $1 + "#, + slug + ) + .fetch_optional(pool) + .await +} + +pub async fn get_all_tags(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as!( + DbTag, + r#" + SELECT id, slug, name, created_at + FROM tags + ORDER BY name ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT + t.id, + t.slug, + t.name, + t.created_at, + COUNT(pt.project_id)::int as "project_count!" + FROM tags t + LEFT JOIN project_tags pt ON t.id = pt.tag_id + GROUP BY t.id, t.slug, t.name, t.created_at + ORDER BY t.name ASC + "# + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| { + let tag = DbTag { + id: row.id, + slug: row.slug, + name: row.name, + created_at: row.created_at, + }; + (tag, row.project_count) + }) + .collect()) +} + +pub async fn update_tag( + pool: &PgPool, + id: Uuid, + name: &str, + slug_override: Option<&str>, +) -> Result { + let slug = slug_override + .map(|s| slugify(s)) + .unwrap_or_else(|| slugify(name)); + + sqlx::query_as!( + DbTag, + r#" + UPDATE tags + SET slug = $2, name = $3 + WHERE id = $1 + RETURNING id, slug, name, created_at + "#, + id, + slug, + name + ) + .fetch_one(pool) + .await +} + +pub async fn delete_tag(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!("DELETE FROM tags WHERE id = $1", id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn tag_exists_by_name(pool: &PgPool, name: &str) -> Result { + let result = sqlx::query!( + r#" + SELECT EXISTS(SELECT 1 FROM tags WHERE LOWER(name) = LOWER($1)) as "exists!" + "#, + name + ) + .fetch_one(pool) + .await?; + + Ok(result.exists) +} + +pub async fn tag_exists_by_slug(pool: &PgPool, slug: &str) -> Result { + let result = sqlx::query!( + r#" + SELECT EXISTS(SELECT 1 FROM tags WHERE slug = $1) as "exists!" + "#, + slug + ) + .fetch_one(pool) + .await?; + + Ok(result.exists) +} + +// Project-Tag association queries + +pub async fn add_tag_to_project( + pool: &PgPool, + project_id: Uuid, + tag_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO project_tags (project_id, tag_id) + VALUES ($1, $2) + ON CONFLICT (project_id, tag_id) DO NOTHING + "#, + project_id, + tag_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn remove_tag_from_project( + pool: &PgPool, + project_id: Uuid, + tag_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM project_tags WHERE project_id = $1 AND tag_id = $2", + project_id, + tag_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn get_tags_for_project( + pool: &PgPool, + project_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as!( + DbTag, + r#" + SELECT t.id, t.slug, t.name, t.created_at + FROM tags t + JOIN project_tags pt ON t.id = pt.tag_id + WHERE pt.project_id = $1 + ORDER BY t.name ASC + "#, + project_id + ) + .fetch_all(pool) + .await +} + +pub async fn get_projects_for_tag( + pool: &PgPool, + tag_id: Uuid, +) -> Result, sqlx::Error> { + sqlx::query_as!( + DbProject, + r#" + SELECT + p.id, + p.slug, + p.title, + p.description, + p.status as "status: ProjectStatus", + p.github_repo, + p.demo_url, + p.priority, + p.icon, + p.last_github_activity, + p.created_at, + p.updated_at + FROM projects p + JOIN project_tags pt ON p.id = pt.project_id + WHERE pt.tag_id = $1 + ORDER BY p.priority DESC, p.created_at DESC + "#, + tag_id + ) + .fetch_all(pool) + .await +} + +// Tag cooccurrence queries + +pub async fn recalculate_tag_cooccurrence(pool: &PgPool) -> Result<(), sqlx::Error> { + // Delete existing cooccurrence data + sqlx::query!("DELETE FROM tag_cooccurrence") + .execute(pool) + .await?; + + // Calculate and insert new cooccurrence data + sqlx::query!( + r#" + INSERT INTO tag_cooccurrence (tag_a, tag_b, count) + SELECT + LEAST(t1.tag_id, t2.tag_id) as tag_a, + GREATEST(t1.tag_id, t2.tag_id) as tag_b, + COUNT(*)::int as count + FROM project_tags t1 + JOIN project_tags t2 ON t1.project_id = t2.project_id + WHERE t1.tag_id < t2.tag_id + GROUP BY tag_a, tag_b + HAVING COUNT(*) > 0 + "# + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_related_tags( + pool: &PgPool, + tag_id: Uuid, + limit: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT + t.id, + t.slug, + t.name, + t.created_at, + tc.count + FROM tag_cooccurrence tc + JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id) + WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1 + ORDER BY tc.count DESC, t.name ASC + LIMIT $2 + "#, + tag_id, + limit + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| { + let tag = DbTag { + id: row.id, + slug: row.slug, + name: row.name, + created_at: row.created_at, + }; + (tag, row.count) + }) + .collect()) +} diff --git a/src/main.rs b/src/main.rs index fa07f36..9b16521 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,12 +86,13 @@ async fn main() { .expect("Failed to connect to database"); // Run migrations on startup - sqlx::migrate!() - .run(&pool) - .await - .expect("Failed to run database migrations"); + tracing::info!("Running database migrations..."); + sqlx::migrate!().run(&pool).await.unwrap_or_else(|e| { + tracing::error!(error = %e, "Migration failed"); + std::process::exit(1); + }); - tracing::info!("Database connected and migrations applied"); + tracing::info!("Migrations applied successfully"); if args.listen.is_empty() { eprintln!("Error: At least one --listen address is required"); @@ -315,6 +316,30 @@ fn api_routes() -> Router> { axum::routing::get(health_handler).head(health_handler), ) .route("/projects", axum::routing::get(projects_handler)) + .route( + "/projects/{id}/tags", + axum::routing::get(get_project_tags_handler).post(add_project_tag_handler), + ) + .route( + "/projects/{id}/tags/{tag_id}", + axum::routing::delete(remove_project_tag_handler), + ) + .route( + "/tags", + axum::routing::get(list_tags_handler).post(create_tag_handler), + ) + .route( + "/tags/{slug}", + axum::routing::get(get_tag_handler).put(update_tag_handler), + ) + .route( + "/tags/{slug}/related", + axum::routing::get(get_related_tags_handler), + ) + .route( + "/tags/recalculate-cooccurrence", + axum::routing::post(recalculate_cooccurrence_handler), + ) .fallback(api_404_and_method_handler) } @@ -466,6 +491,435 @@ async fn projects_handler(State(state): State>) -> impl IntoRespon } } +// Tag API handlers + +async fn list_tags_handler(State(state): State>) -> impl IntoResponse { + match db::get_all_tags_with_counts(&state.pool).await { + Ok(tags_with_counts) => { + let api_tags: Vec = tags_with_counts + .into_iter() + .map(|(tag, count)| db::ApiTagWithCount { + tag: tag.to_api_tag(), + project_count: count, + }) + .collect(); + Json(api_tags).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch tags"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch tags" + })), + ) + .into_response() + } + } +} + +#[derive(serde::Deserialize)] +struct CreateTagRequest { + name: String, + slug: Option, +} + +async fn create_tag_handler( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + if payload.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Tag name cannot be empty" + })), + ) + .into_response(); + } + + match db::create_tag(&state.pool, &payload.name, payload.slug.as_deref()).await { + Ok(tag) => (StatusCode::CREATED, Json(tag.to_api_tag())).into_response(), + Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Conflict", + "message": "A tag with this name or slug already exists" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to create tag"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to create tag" + })), + ) + .into_response() + } + } +} + +async fn get_tag_handler( + State(state): State>, + axum::extract::Path(slug): axum::extract::Path, +) -> impl IntoResponse { + match db::get_tag_by_slug(&state.pool, &slug).await { + Ok(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await { + Ok(projects) => { + let response = serde_json::json!({ + "tag": tag.to_api_tag(), + "projects": projects.into_iter().map(|p| p.to_api_project()).collect::>() + }); + Json(response).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch projects for tag"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch projects" + })), + ) + .into_response() + } + }, + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Tag not found" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to fetch tag"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch tag" + })), + ) + .into_response() + } + } +} + +#[derive(serde::Deserialize)] +struct UpdateTagRequest { + name: String, + slug: Option, +} + +async fn update_tag_handler( + State(state): State>, + axum::extract::Path(slug): axum::extract::Path, + Json(payload): Json, +) -> impl IntoResponse { + if payload.name.trim().is_empty() { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Tag name cannot be empty" + })), + ) + .into_response(); + } + + let tag = match db::get_tag_by_slug(&state.pool, &slug).await { + Ok(Some(tag)) => tag, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Tag not found" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch tag"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch tag" + })), + ) + .into_response(); + } + }; + + match db::update_tag(&state.pool, tag.id, &payload.name, payload.slug.as_deref()).await { + Ok(updated_tag) => Json(updated_tag.to_api_tag()).into_response(), + Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "Conflict", + "message": "A tag with this name or slug already exists" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to update tag"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to update tag" + })), + ) + .into_response() + } + } +} + +async fn get_related_tags_handler( + State(state): State>, + axum::extract::Path(slug): axum::extract::Path, +) -> impl IntoResponse { + let tag = match db::get_tag_by_slug(&state.pool, &slug).await { + Ok(Some(tag)) => tag, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Tag not found" + })), + ) + .into_response(); + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch tag"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch tag" + })), + ) + .into_response(); + } + }; + + match db::get_related_tags(&state.pool, tag.id, 10).await { + Ok(related_tags) => { + let api_related_tags: Vec = related_tags + .into_iter() + .map(|(tag, count)| db::ApiRelatedTag { + tag: tag.to_api_tag(), + cooccurrence_count: count, + }) + .collect(); + Json(api_related_tags).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch related tags"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch related tags" + })), + ) + .into_response() + } + } +} + +async fn recalculate_cooccurrence_handler(State(state): State>) -> impl IntoResponse { + match db::recalculate_tag_cooccurrence(&state.pool).await { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Tag cooccurrence recalculated successfully" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to recalculate cooccurrence"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to recalculate cooccurrence" + })), + ) + .into_response() + } + } +} + +// Project-Tag association handlers + +async fn get_project_tags_handler( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + match db::get_tags_for_project(&state.pool, project_id).await { + Ok(tags) => { + let api_tags: Vec = tags.into_iter().map(|t| t.to_api_tag()).collect(); + Json(api_tags).into_response() + } + Err(err) => { + tracing::error!(error = %err, "Failed to fetch tags for project"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to fetch tags" + })), + ) + .into_response() + } + } +} + +#[derive(serde::Deserialize)] +struct AddProjectTagRequest { + tag_id: String, +} + +async fn add_project_tag_handler( + State(state): State>, + axum::extract::Path(id): axum::extract::Path, + Json(payload): Json, +) -> impl IntoResponse { + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + let tag_id = match uuid::Uuid::parse_str(&payload.tag_id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid tag ID", + "message": "Tag ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + match db::add_tag_to_project(&state.pool, project_id, tag_id).await { + Ok(()) => ( + StatusCode::CREATED, + Json(serde_json::json!({ + "message": "Tag added to project" + })), + ) + .into_response(), + Err(sqlx::Error::Database(db_err)) if db_err.is_foreign_key_violation() => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "message": "Project or tag not found" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to add tag to project"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to add tag to project" + })), + ) + .into_response() + } + } +} + +async fn remove_project_tag_handler( + State(state): State>, + axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>, +) -> impl IntoResponse { + let project_id = match uuid::Uuid::parse_str(&id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid project ID", + "message": "Project ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + let tag_id = match uuid::Uuid::parse_str(&tag_id) { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Invalid tag ID", + "message": "Tag ID must be a valid UUID" + })), + ) + .into_response(); + } + }; + + match db::remove_tag_from_project(&state.pool, project_id, tag_id).await { + Ok(()) => ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Tag removed from project" + })), + ) + .into_response(), + Err(err) => { + tracing::error!(error = %err, "Failed to remove tag from project"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Internal server error", + "message": "Failed to remove tag from project" + })), + ) + .into_response() + } + } +} + fn should_tarpit(state: &TarpitState, path: &str) -> bool { state.config.enabled && is_malicious_path(path) }