S1: Colony backend skeleton — Axum + SQLite, channels + messages CRUD
Monorepo structure:
- crates/colony-types: API types (serde + ts-rs), separate from DB models
- crates/colony: Axum server, SQLite via sqlx, migrations
Working endpoints:
- GET /api/health
- GET/POST /api/channels
- GET /api/channels/{id}
- GET /api/channels/{id}/messages (?since=, ?type=, ?user_id=)
- POST /api/channels/{id}/messages (with type + metadata)
Data model includes:
- seq monotonic ordering, soft delete, same-channel reply constraint
- Seeded users (benji, neeraj) and #general channel
Also: codex-review skill, .gitignore
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
227
crates/colony/src/routes.rs
Normal file
227
crates/colony/src/routes.rs
Normal file
@@ -0,0 +1,227 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use colony_types::*;
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::*;
|
||||
|
||||
// ── Health ──
|
||||
|
||||
pub async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
// ── Channels ──
|
||||
|
||||
pub async fn list_channels(State(db): State<SqlitePool>) -> impl IntoResponse {
|
||||
let rows = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels ORDER BY created_at")
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channels: Vec<Channel> = rows.iter().map(|r| r.to_api()).collect();
|
||||
Json(channels)
|
||||
}
|
||||
|
||||
pub async fn create_channel(
|
||||
State(db): State<SqlitePool>,
|
||||
Json(body): Json<CreateChannel>,
|
||||
) -> impl IntoResponse {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
// Hardcoded to benji for now (no auth yet)
|
||||
let created_by = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
|
||||
.bind(&id)
|
||||
.bind(&body.name)
|
||||
.bind(&body.description)
|
||||
.bind(created_by)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(StatusCode::CREATED, Json(row.to_api()))
|
||||
}
|
||||
|
||||
pub async fn get_channel(
|
||||
State(db): State<SqlitePool>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Json(r.to_api())),
|
||||
None => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Messages ──
|
||||
|
||||
pub async fn list_messages(
|
||||
State(db): State<SqlitePool>,
|
||||
Path(channel_id): Path<String>,
|
||||
Query(query): Query<MessageQuery>,
|
||||
) -> impl IntoResponse {
|
||||
// Build query dynamically based on filters
|
||||
let mut sql = String::from(
|
||||
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
|
||||
FROM messages m JOIN users u ON m.user_id = u.id \
|
||||
WHERE m.channel_id = ?",
|
||||
);
|
||||
let mut binds: Vec<String> = vec![channel_id.clone()];
|
||||
|
||||
if let Some(since) = &query.since {
|
||||
sql.push_str(" AND m.created_at > ?");
|
||||
binds.push(since.to_rfc3339());
|
||||
}
|
||||
if let Some(msg_type) = &query.r#type {
|
||||
sql.push_str(" AND m.type = ?");
|
||||
binds.push(match msg_type {
|
||||
MessageType::Text => "text",
|
||||
MessageType::Code => "code",
|
||||
MessageType::Result => "result",
|
||||
MessageType::Error => "error",
|
||||
MessageType::Plan => "plan",
|
||||
}.to_string());
|
||||
}
|
||||
if let Some(user_id) = &query.user_id {
|
||||
sql.push_str(" AND m.user_id = ?");
|
||||
binds.push(user_id.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY m.seq ASC");
|
||||
|
||||
// Use raw query with dynamic binds
|
||||
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
|
||||
for b in &binds {
|
||||
q = q.bind(b);
|
||||
}
|
||||
|
||||
let rows = q.fetch_all(&db).await.unwrap();
|
||||
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
|
||||
Json(messages)
|
||||
}
|
||||
|
||||
pub async fn post_message(
|
||||
State(db): State<SqlitePool>,
|
||||
Path(channel_id): Path<String>,
|
||||
Json(body): Json<PostMessage>,
|
||||
) -> impl IntoResponse {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
// Hardcoded to benji for now (no auth yet)
|
||||
let user_id = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
let msg_type = match body.r#type {
|
||||
MessageType::Text => "text",
|
||||
MessageType::Code => "code",
|
||||
MessageType::Result => "result",
|
||||
MessageType::Error => "error",
|
||||
MessageType::Plan => "plan",
|
||||
};
|
||||
|
||||
let metadata_json = body.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap());
|
||||
let reply_to = body.reply_to.map(|r| r.to_string());
|
||||
|
||||
// Get next seq
|
||||
let seq: i64 = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COALESCE((SELECT next_seq FROM channel_seq WHERE channel_id = ?), 1)",
|
||||
)
|
||||
.bind(&channel_id)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO messages (id, seq, channel_id, user_id, type, content, metadata, reply_to) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(seq)
|
||||
.bind(&channel_id)
|
||||
.bind(user_id)
|
||||
.bind(msg_type)
|
||||
.bind(&body.content)
|
||||
.bind(&metadata_json)
|
||||
.bind(&reply_to)
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fetch the full message with user
|
||||
let row = sqlx::query_as::<_, MessageWithUserRow>(
|
||||
"SELECT m.*, u.id as u_id, u.username, u.display_name, u.role, u.created_at as u_created_at \
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(StatusCode::CREATED, Json(row.to_api_message()))
|
||||
}
|
||||
|
||||
// ── Joined row type for message + user ──
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct MessageWithUserRow {
|
||||
// message fields
|
||||
pub id: String,
|
||||
pub seq: i64,
|
||||
pub channel_id: String,
|
||||
pub user_id: String,
|
||||
pub r#type: String,
|
||||
pub content: String,
|
||||
pub metadata: Option<String>,
|
||||
pub reply_to: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: Option<String>,
|
||||
pub deleted_at: Option<String>,
|
||||
// user fields (aliased)
|
||||
pub u_id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub u_created_at: String,
|
||||
}
|
||||
|
||||
impl MessageWithUserRow {
|
||||
pub fn to_api_message(&self) -> Message {
|
||||
let user_row = UserRow {
|
||||
id: self.u_id.clone(),
|
||||
username: self.username.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
role: self.role.clone(),
|
||||
password_hash: None,
|
||||
created_at: self.u_created_at.clone(),
|
||||
};
|
||||
let msg_row = MessageRow {
|
||||
id: self.id.clone(),
|
||||
seq: self.seq,
|
||||
channel_id: self.channel_id.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
r#type: self.r#type.clone(),
|
||||
content: self.content.clone(),
|
||||
metadata: self.metadata.clone(),
|
||||
reply_to: self.reply_to.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
updated_at: self.updated_at.clone(),
|
||||
deleted_at: self.deleted_at.clone(),
|
||||
};
|
||||
msg_row.to_api(&user_row)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user