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:
2026-01-06 02:07:58 -06:00
parent b4c708335b
commit 045781f7a5
25 changed files with 1610 additions and 8 deletions
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tag_cooccurrence",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "2487d244576ae4f02e72f74b5a1501040c47a4206c4cfea566f17641ec82d160"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM tags WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "dd0d0e3fd03f130aab947d13580796eee9a786e2ca01d339fd0e8356f8ad3824"
}
@@ -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"
}
@@ -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"
}
+7 -3
View File
@@ -88,9 +88,13 @@ docker-run port="8080":
just docker-run-json {{port}} | hl --config .hl.config.toml -P just docker-run-json {{port}} | hl --config .hl.config.toml -P
docker-run-json port="8080": docker-run-json port="8080":
#!/usr/bin/env bash
set -euo pipefail
docker stop xevion-dev-container 2>/dev/null || true docker stop xevion-dev-container 2>/dev/null || true
docker rm 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")] [script("bun")]
seed: seed:
@@ -159,8 +163,8 @@ db cmd="start":
} else if (CMD === "reset") { } else if (CMD === "reset") {
if (!container) create(); if (!container) create();
else { else {
run(["exec", NAME, "psql", "-U", USER, "-c", `DROP DATABASE IF EXISTS ${DB}`]); run(["exec", NAME, "psql", "-U", USER, "-d", "postgres", "-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", `CREATE DATABASE ${DB}`]);
console.log("✅ reset"); console.log("✅ reset");
} }
await updateEnv(); 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
View File
@@ -99,5 +99,115 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("✅ Seeded {} projects", project_count); 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(()) Ok(())
} }
+394
View File
@@ -31,6 +31,28 @@ pub struct DbProject {
pub updated_at: OffsetDateTime, 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 // API response types
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiProjectLink { pub struct ApiProjectLink {
@@ -42,6 +64,7 @@ pub struct ApiProjectLink {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiProject { pub struct ApiProject {
pub id: String, pub id: String,
pub slug: String,
pub name: String, pub name: String,
#[serde(rename = "shortDescription")] #[serde(rename = "shortDescription")]
pub short_description: String, pub short_description: String,
@@ -50,6 +73,45 @@ pub struct ApiProject {
pub links: Vec<ApiProjectLink>, 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 { impl DbProject {
/// Convert database project to API response format /// Convert database project to API response format
pub fn to_api_project(&self) -> ApiProject { pub fn to_api_project(&self) -> ApiProject {
@@ -71,6 +133,7 @@ impl DbProject {
ApiProject { ApiProject {
id: self.id.to_string(), id: self.id.to_string(),
slug: self.slug.clone(),
name: self.title.clone(), name: self.title.clone(),
short_description: self.description.clone(), short_description: self.description.clone(),
icon: self.icon.clone(), icon: self.icon.clone(),
@@ -121,3 +184,334 @@ pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> {
.await .await
.map(|_| ()) .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
View File
@@ -86,12 +86,13 @@ async fn main() {
.expect("Failed to connect to database"); .expect("Failed to connect to database");
// Run migrations on startup // Run migrations on startup
sqlx::migrate!() tracing::info!("Running database migrations...");
.run(&pool) sqlx::migrate!().run(&pool).await.unwrap_or_else(|e| {
.await tracing::error!(error = %e, "Migration failed");
.expect("Failed to run database migrations"); std::process::exit(1);
});
tracing::info!("Database connected and migrations applied"); tracing::info!("Migrations applied successfully");
if args.listen.is_empty() { if args.listen.is_empty() {
eprintln!("Error: At least one --listen address is required"); 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), axum::routing::get(health_handler).head(health_handler),
) )
.route("/projects", axum::routing::get(projects_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) .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 { fn should_tarpit(state: &TarpitState, path: &str) -> bool {
state.config.enabled && is_malicious_path(path) state.config.enabled && is_malicious_path(path)
} }