mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 08:26:41 -06:00
feat: add comprehensive tagging system with cooccurrence tracking
- Add tags, project_tags, and tag_cooccurrence tables with proper indexes - Implement full CRUD API endpoints for tag management - Add tag association endpoints for projects with automatic cooccurrence updates - Include related tags and project filtering by tag functionality
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM tag_cooccurrence",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160"
|
||||
}
|
||||
+42
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+100
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM tags WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+38
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
+110
@@ -99,5 +99,115 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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<ApiProjectLink>,
|
||||
}
|
||||
|
||||
#[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<ApiTag>,
|
||||
}
|
||||
|
||||
#[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::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
// Tag CRUD queries
|
||||
|
||||
pub async fn create_tag(
|
||||
pool: &PgPool,
|
||||
name: &str,
|
||||
slug_override: Option<&str>,
|
||||
) -> Result<DbTag, sqlx::Error> {
|
||||
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<Option<DbTag>, 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<Option<DbTag>, 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<Vec<DbTag>, 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<Vec<(DbTag, i32)>, 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<DbTag, sqlx::Error> {
|
||||
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<bool, sqlx::Error> {
|
||||
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<bool, sqlx::Error> {
|
||||
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<Vec<DbTag>, 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<Vec<DbProject>, 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<Vec<(DbTag, i32)>, 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())
|
||||
}
|
||||
|
||||
+459
-5
@@ -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<Arc<AppState>> {
|
||||
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<Arc<AppState>>) -> impl IntoRespon
|
||||
}
|
||||
}
|
||||
|
||||
// Tag API handlers
|
||||
|
||||
async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
match db::get_all_tags_with_counts(&state.pool).await {
|
||||
Ok(tags_with_counts) => {
|
||||
let api_tags: Vec<db::ApiTagWithCount> = 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<String>,
|
||||
}
|
||||
|
||||
async fn create_tag_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<CreateTagRequest>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
axum::extract::Path(slug): axum::extract::Path<String>,
|
||||
) -> 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::<Vec<_>>()
|
||||
});
|
||||
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<String>,
|
||||
}
|
||||
|
||||
async fn update_tag_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::extract::Path(slug): axum::extract::Path<String>,
|
||||
Json(payload): Json<UpdateTagRequest>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
axum::extract::Path(slug): axum::extract::Path<String>,
|
||||
) -> 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<db::ApiRelatedTag> = 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<Arc<AppState>>) -> 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<Arc<AppState>>,
|
||||
axum::extract::Path(id): axum::extract::Path<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();
|
||||
}
|
||||
};
|
||||
|
||||
match db::get_tags_for_project(&state.pool, project_id).await {
|
||||
Ok(tags) => {
|
||||
let api_tags: Vec<db::ApiTag> = 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<Arc<AppState>>,
|
||||
axum::extract::Path(id): axum::extract::Path<String>,
|
||||
Json(payload): Json<AddProjectTagRequest>,
|
||||
) -> 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<Arc<AppState>>,
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user