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:
36
.claude/skills/codex-review/SKILL.md
Normal file
36
.claude/skills/codex-review/SKILL.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: codex-review
|
||||
description: Get a parallel GPT-5.4 review of code, plans, or decisions. Use when you want a second opinion, finished a non-trivial implementation, or want to validate an approach before presenting to the ape.
|
||||
argument-hint: "[what to review — file paths, plan description, or question]"
|
||||
---
|
||||
|
||||
# Codex Review
|
||||
|
||||
Get an independent GPT-5.4 review via codex CLI. Use this proactively after finishing non-trivial work, or when you want a second opinion before proceeding.
|
||||
|
||||
## When to use
|
||||
|
||||
- After writing a significant chunk of code
|
||||
- Before presenting an architecture decision to the ape
|
||||
- When you're unsure about an approach
|
||||
- After a refactor to catch regressions
|
||||
- When the critic skill feels too heavy (critic is for decisions, this is for code review)
|
||||
|
||||
## Execution
|
||||
|
||||
Run this command via Bash, substituting the review request:
|
||||
|
||||
```bash
|
||||
codex exec -c 'reasoning_effort="high"' "Review: $ARGUMENTS. Read all relevant files. Be specific — file paths, line numbers, exact issues. What bugs, edge cases, or design problems exist? Do NOT spawn sub-agents. Answer directly in bullet points." 2>&1
|
||||
```
|
||||
|
||||
**Can run in background** (`run_in_background: true`) if you have other work to do in parallel.
|
||||
|
||||
## Output
|
||||
|
||||
Present the codex findings to the ape as:
|
||||
- Issues found (if any)
|
||||
- Agreements with your approach (if reviewing your own work)
|
||||
- Disagreements to address
|
||||
|
||||
If codex found real issues in your code, fix them before presenting to the ape. Apes don't do tasks.
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
2479
Cargo.lock
generated
Normal file
2479
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||
ts-rs = { version = "10", features = ["serde-json-impl", "uuid-impl", "chrono-impl"] }
|
||||
thiserror = "2"
|
||||
11
crates/colony-types/Cargo.toml
Normal file
11
crates/colony-types/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "colony-types"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
119
crates/colony-types/src/lib.rs
Normal file
119
crates/colony-types/src/lib.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── API response types (wire format → TypeScript via ts-rs) ──
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: UserRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UserRole {
|
||||
Ape,
|
||||
Agent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Channel {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created_by: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Message {
|
||||
pub id: Uuid,
|
||||
pub seq: i64,
|
||||
pub channel_id: Uuid,
|
||||
pub user: User,
|
||||
pub r#type: MessageType,
|
||||
pub content: String,
|
||||
#[ts(optional)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[ts(optional)]
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[ts(optional)]
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
#[ts(optional)]
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS, PartialEq)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageType {
|
||||
Text,
|
||||
Code,
|
||||
Result,
|
||||
Error,
|
||||
Plan,
|
||||
}
|
||||
|
||||
// ── Request types ──
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CreateChannel {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct PostMessage {
|
||||
pub content: String,
|
||||
pub r#type: MessageType,
|
||||
#[ts(optional)]
|
||||
pub metadata: Option<serde_json::Value>,
|
||||
#[ts(optional)]
|
||||
pub reply_to: Option<Uuid>,
|
||||
}
|
||||
|
||||
// ── WebSocket event types ──
|
||||
|
||||
#[derive(Debug, Clone, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WsEvent {
|
||||
Message(Message),
|
||||
Edit(Message),
|
||||
Delete { id: Uuid },
|
||||
}
|
||||
|
||||
// ── Query params ──
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MessageQuery {
|
||||
pub since: Option<DateTime<Utc>>,
|
||||
pub r#type: Option<MessageType>,
|
||||
pub user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn export_ts_types() {
|
||||
// This test generates TypeScript bindings.
|
||||
// Run: cargo test -p colony-types export_ts_types
|
||||
// Files go to colony-types/bindings/
|
||||
let _ = User::export_all();
|
||||
}
|
||||
}
|
||||
16
crates/colony/Cargo.toml
Normal file
16
crates/colony/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "colony"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
colony-types = { path = "../colony-types" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
75
crates/colony/migrations/001_init.sql
Normal file
75
crates/colony/migrations/001_init.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('ape', 'agent')),
|
||||
password_hash TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id),
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
type TEXT NOT NULL CHECK (type IN ('text', 'code', 'result', 'error', 'plan')),
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
reply_to TEXT REFERENCES messages(id),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(channel_id, created_at);
|
||||
|
||||
-- Auto-increment seq per channel using a trigger
|
||||
CREATE TABLE IF NOT EXISTS channel_seq (
|
||||
channel_id TEXT PRIMARY KEY NOT NULL REFERENCES channels(id),
|
||||
next_seq INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_message_seq
|
||||
AFTER INSERT ON messages
|
||||
BEGIN
|
||||
INSERT INTO channel_seq (channel_id, next_seq) VALUES (NEW.channel_id, 2)
|
||||
ON CONFLICT(channel_id) DO UPDATE SET next_seq = next_seq + 1;
|
||||
END;
|
||||
|
||||
-- Enforce reply_to same-channel constraint
|
||||
CREATE TRIGGER IF NOT EXISTS trg_reply_same_channel
|
||||
BEFORE INSERT ON messages
|
||||
WHEN NEW.reply_to IS NOT NULL
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'reply_to must reference a message in the same channel')
|
||||
WHERE (SELECT channel_id FROM messages WHERE id = NEW.reply_to) != NEW.channel_id;
|
||||
END;
|
||||
|
||||
-- Seed user for development (no auth yet)
|
||||
INSERT OR IGNORE INTO users (id, username, display_name, role)
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'benji', 'Benji', 'ape');
|
||||
|
||||
INSERT OR IGNORE INTO users (id, username, display_name, role)
|
||||
VALUES ('00000000-0000-0000-0000-000000000002', 'neeraj', 'Neeraj', 'ape');
|
||||
|
||||
-- Seed a general channel
|
||||
INSERT OR IGNORE INTO channels (id, name, description, created_by)
|
||||
VALUES ('00000000-0000-0000-0000-000000000010', 'general', 'General discussion', '00000000-0000-0000-0000-000000000001');
|
||||
106
crates/colony/src/db.rs
Normal file
106
crates/colony/src/db.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::FromRow;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// DB row types — these map directly to SQL tables.
|
||||
/// Separate from API types in colony-types.
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct UserRow {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct ChannelRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
pub struct MessageRow {
|
||||
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>,
|
||||
}
|
||||
|
||||
// ── Conversions from DB rows to API types ──
|
||||
|
||||
impl UserRow {
|
||||
pub fn to_api(&self) -> colony_types::User {
|
||||
colony_types::User {
|
||||
id: Uuid::parse_str(&self.id).unwrap(),
|
||||
username: self.username.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
role: match self.role.as_str() {
|
||||
"agent" => colony_types::UserRole::Agent,
|
||||
_ => colony_types::UserRole::Ape,
|
||||
},
|
||||
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelRow {
|
||||
pub fn to_api(&self) -> colony_types::Channel {
|
||||
colony_types::Channel {
|
||||
id: Uuid::parse_str(&self.id).unwrap(),
|
||||
name: self.name.clone(),
|
||||
description: self.description.clone(),
|
||||
created_by: Uuid::parse_str(&self.created_by).unwrap(),
|
||||
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageRow {
|
||||
pub fn to_api(&self, user: &UserRow) -> colony_types::Message {
|
||||
colony_types::Message {
|
||||
id: Uuid::parse_str(&self.id).unwrap(),
|
||||
seq: self.seq,
|
||||
channel_id: Uuid::parse_str(&self.channel_id).unwrap(),
|
||||
user: user.to_api(),
|
||||
r#type: match self.r#type.as_str() {
|
||||
"code" => colony_types::MessageType::Code,
|
||||
"result" => colony_types::MessageType::Result,
|
||||
"error" => colony_types::MessageType::Error,
|
||||
"plan" => colony_types::MessageType::Plan,
|
||||
_ => colony_types::MessageType::Text,
|
||||
},
|
||||
content: if self.deleted_at.is_some() {
|
||||
"[deleted]".to_string()
|
||||
} else {
|
||||
self.content.clone()
|
||||
},
|
||||
metadata: self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| serde_json::from_str(m).ok()),
|
||||
reply_to: self.reply_to.as_ref().and_then(|r| Uuid::parse_str(r).ok()),
|
||||
created_at: self.created_at.parse::<DateTime<Utc>>().unwrap(),
|
||||
updated_at: self
|
||||
.updated_at
|
||||
.as_ref()
|
||||
.and_then(|t| t.parse::<DateTime<Utc>>().ok()),
|
||||
deleted_at: self
|
||||
.deleted_at
|
||||
.as_ref()
|
||||
.and_then(|t| t.parse::<DateTime<Utc>>().ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/colony/src/main.rs
Normal file
53
crates/colony/src/main.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
mod db;
|
||||
mod routes;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use std::env;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:colony.db?mode=rwc".into());
|
||||
let port = env::var("PORT").unwrap_or_else(|_| "3001".into());
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&db_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Enable WAL mode
|
||||
sqlx::query("PRAGMA journal_mode=WAL")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Run migrations
|
||||
let migration_sql = include_str!("../migrations/001_init.sql");
|
||||
for statement in migration_sql.split(';') {
|
||||
let stmt = statement.trim();
|
||||
if !stmt.is_empty() {
|
||||
if let Err(e) = sqlx::query(stmt).execute(&pool).await {
|
||||
eprintln!("Migration warning (may be OK): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Colony running on port {}", port);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/health", get(routes::health))
|
||||
.route("/api/channels", get(routes::list_channels).post(routes::create_channel))
|
||||
.route("/api/channels/{id}", get(routes::get_channel))
|
||||
.route(
|
||||
"/api/channels/{channel_id}/messages",
|
||||
get(routes::list_messages).post(routes::post_message),
|
||||
)
|
||||
.with_state(pool);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
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