fix: all 5 high-severity bugs from codex review
- use sqlx migrate!() instead of broken split(';') — triggers now work
- seq via AUTOINCREMENT — no race conditions, monotonic ordering
- replace ?since= with ?after_seq= — cursor-based, no timestamp format issues
- replace all unwrap() with typed errors (404, 409, 400, 500)
- reply_to same-channel enforced in route handler
- add biome for frontend linting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ serde_json = "1"
|
|||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono", "uuid", "migrate"] }
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
tower-http = { version = "0.6", features = ["cors", "fs"] }
|
||||||
ts-rs = { version = "10", features = ["serde-json-impl", "uuid-impl", "chrono-impl"] }
|
ts-rs = { version = "10", features = ["serde-json-impl", "uuid-impl", "chrono-impl"] }
|
||||||
|
|||||||
3
crates/colony-types/bindings/Channel.ts
Normal file
3
crates/colony-types/bindings/Channel.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type Channel = { id: string, name: string, description: string, created_by: string, created_at: string, };
|
||||||
3
crates/colony-types/bindings/CreateChannel.ts
Normal file
3
crates/colony-types/bindings/CreateChannel.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type CreateChannel = { name: string, description: string, };
|
||||||
6
crates/colony-types/bindings/Message.ts
Normal file
6
crates/colony-types/bindings/Message.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { JsonValue } from "./serde_json/JsonValue";
|
||||||
|
import type { MessageType } from "./MessageType";
|
||||||
|
import type { User } from "./User";
|
||||||
|
|
||||||
|
export type Message = { id: string, seq: bigint, channel_id: string, user: User, type: MessageType, content: string, metadata?: JsonValue, reply_to?: string, created_at: string, updated_at?: string, deleted_at?: string, };
|
||||||
3
crates/colony-types/bindings/MessageType.ts
Normal file
3
crates/colony-types/bindings/MessageType.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type MessageType = "text" | "code" | "result" | "error" | "plan";
|
||||||
5
crates/colony-types/bindings/PostMessage.ts
Normal file
5
crates/colony-types/bindings/PostMessage.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { JsonValue } from "./serde_json/JsonValue";
|
||||||
|
import type { MessageType } from "./MessageType";
|
||||||
|
|
||||||
|
export type PostMessage = { content: string, type: MessageType, metadata?: JsonValue, reply_to?: string, };
|
||||||
4
crates/colony-types/bindings/User.ts
Normal file
4
crates/colony-types/bindings/User.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { UserRole } from "./UserRole";
|
||||||
|
|
||||||
|
export type User = { id: string, username: string, display_name: string, role: UserRole, created_at: string, };
|
||||||
3
crates/colony-types/bindings/UserRole.ts
Normal file
3
crates/colony-types/bindings/UserRole.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type UserRole = "ape" | "agent";
|
||||||
4
crates/colony-types/bindings/WsEvent.ts
Normal file
4
crates/colony-types/bindings/WsEvent.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Message } from "./Message";
|
||||||
|
|
||||||
|
export type WsEvent = { "event": "message", "data": Message } | { "event": "edit", "data": Message } | { "event": "delete", "data": { id: string, } };
|
||||||
3
crates/colony-types/bindings/serde_json/JsonValue.ts
Normal file
3
crates/colony-types/bindings/serde_json/JsonValue.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;
|
||||||
@@ -100,7 +100,7 @@ pub enum WsEvent {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct MessageQuery {
|
pub struct MessageQuery {
|
||||||
pub since: Option<DateTime<Utc>>,
|
pub after_seq: Option<i64>,
|
||||||
pub r#type: Option<MessageType>,
|
pub r#type: Option<MessageType>,
|
||||||
pub user_id: Option<Uuid>,
|
pub user_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ CREATE TABLE IF NOT EXISTS channels (
|
|||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- seq is INTEGER PRIMARY KEY AUTOINCREMENT = global monotonic, no race conditions
|
||||||
|
-- id is a separate UUID with a unique index
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
seq INTEGER NOT NULL,
|
id TEXT UNIQUE NOT NULL,
|
||||||
channel_id TEXT NOT NULL REFERENCES channels(id),
|
channel_id TEXT NOT NULL REFERENCES channels(id),
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
type TEXT NOT NULL CHECK (type IN ('text', 'code', 'result', 'error', 'plan')),
|
type TEXT NOT NULL CHECK (type IN ('text', 'code', 'result', 'error', 'plan')),
|
||||||
@@ -36,40 +38,18 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
deleted_at TEXT
|
deleted_at TEXT
|
||||||
|
-- reply_to same-channel constraint enforced in application layer (routes.rs)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, seq);
|
CREATE INDEX IF NOT EXISTS idx_messages_channel_seq 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
|
-- Seed users
|
||||||
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)
|
INSERT OR IGNORE INTO users (id, username, display_name, role)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000001', 'benji', 'Benji', 'ape');
|
VALUES ('00000000-0000-0000-0000-000000000001', 'benji', 'Benji', 'ape');
|
||||||
|
|
||||||
INSERT OR IGNORE INTO users (id, username, display_name, role)
|
INSERT OR IGNORE INTO users (id, username, display_name, role)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000002', 'neeraj', 'Neeraj', 'ape');
|
VALUES ('00000000-0000-0000-0000-000000000002', 'neeraj', 'Neeraj', 'ape');
|
||||||
|
|
||||||
-- Seed a general channel
|
-- Seed general channel
|
||||||
INSERT OR IGNORE INTO channels (id, name, description, created_by)
|
INSERT OR IGNORE INTO channels (id, name, description, created_by)
|
||||||
VALUES ('00000000-0000-0000-0000-000000000010', 'general', 'General discussion', '00000000-0000-0000-0000-000000000001');
|
VALUES ('00000000-0000-0000-0000-000000000010', 'general', 'General discussion', '00000000-0000-0000-0000-000000000001');
|
||||||
@@ -22,22 +22,20 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations using sqlx's proper migration system
|
||||||
let migration_sql = include_str!("../migrations/001_init.sql");
|
sqlx::migrate!("./migrations")
|
||||||
for statement in migration_sql.split(';') {
|
.run(&pool)
|
||||||
let stmt = statement.trim();
|
.await
|
||||||
if !stmt.is_empty() {
|
.expect("Failed to run migrations");
|
||||||
if let Err(e) = sqlx::query(stmt).execute(&pool).await {
|
|
||||||
eprintln!("Migration warning (may be OK): {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("Colony running on port {}", port);
|
println!("Colony running on port {}", port);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/health", get(routes::health))
|
.route("/api/health", get(routes::health))
|
||||||
.route("/api/channels", get(routes::list_channels).post(routes::create_channel))
|
.route(
|
||||||
|
"/api/channels",
|
||||||
|
get(routes::list_channels).post(routes::create_channel),
|
||||||
|
)
|
||||||
.route("/api/channels/{id}", get(routes::get_channel))
|
.route("/api/channels/{id}", get(routes::get_channel))
|
||||||
.route(
|
.route(
|
||||||
"/api/channels/{channel_id}/messages",
|
"/api/channels/{channel_id}/messages",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use colony_types::*;
|
use colony_types::*;
|
||||||
@@ -10,6 +10,41 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::db::*;
|
use crate::db::*;
|
||||||
|
|
||||||
|
// ── Error handling ──
|
||||||
|
|
||||||
|
pub enum AppError {
|
||||||
|
NotFound(String),
|
||||||
|
Conflict(String),
|
||||||
|
BadRequest(String),
|
||||||
|
Internal(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match self {
|
||||||
|
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m),
|
||||||
|
AppError::Conflict(m) => (StatusCode::CONFLICT, m),
|
||||||
|
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||||
|
AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m),
|
||||||
|
};
|
||||||
|
(status, Json(serde_json::json!({"error": message}))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AppError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
match &e {
|
||||||
|
sqlx::Error::Database(db_err) if db_err.message().contains("UNIQUE") => {
|
||||||
|
AppError::Conflict(format!("Already exists: {}", db_err.message()))
|
||||||
|
}
|
||||||
|
sqlx::Error::RowNotFound => AppError::NotFound("Not found".into()),
|
||||||
|
_ => AppError::Internal(format!("Database error: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, AppError>;
|
||||||
|
|
||||||
// ── Health ──
|
// ── Health ──
|
||||||
|
|
||||||
pub async fn health() -> &'static str {
|
pub async fn health() -> &'static str {
|
||||||
@@ -18,22 +53,21 @@ pub async fn health() -> &'static str {
|
|||||||
|
|
||||||
// ── Channels ──
|
// ── Channels ──
|
||||||
|
|
||||||
pub async fn list_channels(State(db): State<SqlitePool>) -> impl IntoResponse {
|
pub async fn list_channels(State(db): State<SqlitePool>) -> Result<Json<Vec<Channel>>> {
|
||||||
let rows = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels ORDER BY created_at")
|
let rows = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels ORDER BY created_at")
|
||||||
.fetch_all(&db)
|
.fetch_all(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let channels: Vec<Channel> = rows.iter().map(|r| r.to_api()).collect();
|
let channels: Vec<Channel> = rows.iter().map(|r| r.to_api()).collect();
|
||||||
Json(channels)
|
Ok(Json(channels))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_channel(
|
pub async fn create_channel(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Json(body): Json<CreateChannel>,
|
Json(body): Json<CreateChannel>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
// Hardcoded to benji for now (no auth yet)
|
// Hardcoded to benji for now (no auth yet — S4 will extract from middleware)
|
||||||
let created_by = "00000000-0000-0000-0000-000000000001";
|
let created_by = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
|
sqlx::query("INSERT INTO channels (id, name, description, created_by) VALUES (?, ?, ?, ?)")
|
||||||
@@ -42,31 +76,28 @@ pub async fn create_channel(
|
|||||||
.bind(&body.description)
|
.bind(&body.description)
|
||||||
.bind(created_by)
|
.bind(created_by)
|
||||||
.execute(&db)
|
.execute(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_one(&db)
|
.fetch_one(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
(StatusCode::CREATED, Json(row.to_api()))
|
Ok((StatusCode::CREATED, Json(row.to_api())))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_channel(
|
pub async fn get_channel(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<Json<Channel>> {
|
||||||
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
let row = sqlx::query_as::<_, ChannelRow>("SELECT * FROM channels WHERE id = ?")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_optional(&db)
|
.fetch_optional(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some(r) => Ok(Json(r.to_api())),
|
Some(r) => Ok(Json(r.to_api())),
|
||||||
None => Err(StatusCode::NOT_FOUND),
|
None => Err(AppError::NotFound(format!("Channel {id} not found"))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +107,7 @@ pub async fn list_messages(
|
|||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Path(channel_id): Path<String>,
|
Path(channel_id): Path<String>,
|
||||||
Query(query): Query<MessageQuery>,
|
Query(query): Query<MessageQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<Json<Vec<Message>>> {
|
||||||
// Build query dynamically based on filters
|
|
||||||
let mut sql = String::from(
|
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 \
|
"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 \
|
FROM messages m JOIN users u ON m.user_id = u.id \
|
||||||
@@ -85,9 +115,9 @@ pub async fn list_messages(
|
|||||||
);
|
);
|
||||||
let mut binds: Vec<String> = vec![channel_id.clone()];
|
let mut binds: Vec<String> = vec![channel_id.clone()];
|
||||||
|
|
||||||
if let Some(since) = &query.since {
|
if let Some(after_seq) = &query.after_seq {
|
||||||
sql.push_str(" AND m.created_at > ?");
|
sql.push_str(" AND m.seq > ?");
|
||||||
binds.push(since.to_rfc3339());
|
binds.push(after_seq.to_string());
|
||||||
}
|
}
|
||||||
if let Some(msg_type) = &query.r#type {
|
if let Some(msg_type) = &query.r#type {
|
||||||
sql.push_str(" AND m.type = ?");
|
sql.push_str(" AND m.type = ?");
|
||||||
@@ -97,7 +127,8 @@ pub async fn list_messages(
|
|||||||
MessageType::Result => "result",
|
MessageType::Result => "result",
|
||||||
MessageType::Error => "error",
|
MessageType::Error => "error",
|
||||||
MessageType::Plan => "plan",
|
MessageType::Plan => "plan",
|
||||||
}.to_string());
|
}
|
||||||
|
.to_string());
|
||||||
}
|
}
|
||||||
if let Some(user_id) = &query.user_id {
|
if let Some(user_id) = &query.user_id {
|
||||||
sql.push_str(" AND m.user_id = ?");
|
sql.push_str(" AND m.user_id = ?");
|
||||||
@@ -106,24 +137,53 @@ pub async fn list_messages(
|
|||||||
|
|
||||||
sql.push_str(" ORDER BY m.seq ASC");
|
sql.push_str(" ORDER BY m.seq ASC");
|
||||||
|
|
||||||
// Use raw query with dynamic binds
|
|
||||||
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
|
let mut q = sqlx::query_as::<_, MessageWithUserRow>(&sql);
|
||||||
for b in &binds {
|
for b in &binds {
|
||||||
q = q.bind(b);
|
q = q.bind(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = q.fetch_all(&db).await.unwrap();
|
let rows = q.fetch_all(&db).await?;
|
||||||
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
|
let messages: Vec<Message> = rows.iter().map(|r| r.to_api_message()).collect();
|
||||||
Json(messages)
|
Ok(Json(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_message(
|
pub async fn post_message(
|
||||||
State(db): State<SqlitePool>,
|
State(db): State<SqlitePool>,
|
||||||
Path(channel_id): Path<String>,
|
Path(channel_id): Path<String>,
|
||||||
Json(body): Json<PostMessage>,
|
Json(body): Json<PostMessage>,
|
||||||
) -> impl IntoResponse {
|
) -> Result<impl IntoResponse> {
|
||||||
|
// Verify channel exists
|
||||||
|
let channel_exists = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM channels WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(&channel_id)
|
||||||
|
.fetch_one(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if channel_exists == 0 {
|
||||||
|
return Err(AppError::NotFound(format!("Channel {channel_id} not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify reply_to is in same channel (if provided)
|
||||||
|
if let Some(ref reply_id) = body.reply_to {
|
||||||
|
let reply_channel = sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT channel_id FROM messages WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(reply_id.to_string())
|
||||||
|
.fetch_optional(&db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match reply_channel {
|
||||||
|
None => return Err(AppError::BadRequest(format!("reply_to message {reply_id} not found"))),
|
||||||
|
Some(ch) if ch != channel_id => {
|
||||||
|
return Err(AppError::BadRequest("reply_to must reference a message in the same channel".into()));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
// Hardcoded to benji for now (no auth yet)
|
// Hardcoded to benji for now (no auth yet — S4 will extract from middleware)
|
||||||
let user_id = "00000000-0000-0000-0000-000000000001";
|
let user_id = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
let msg_type = match body.r#type {
|
let msg_type = match body.r#type {
|
||||||
@@ -134,24 +194,18 @@ pub async fn post_message(
|
|||||||
MessageType::Plan => "plan",
|
MessageType::Plan => "plan",
|
||||||
};
|
};
|
||||||
|
|
||||||
let metadata_json = body.metadata.as_ref().map(|m| serde_json::to_string(m).unwrap());
|
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());
|
let reply_to = body.reply_to.map(|r| r.to_string());
|
||||||
|
|
||||||
// Get next seq
|
// seq is AUTOINCREMENT — no race conditions, no manual tracking
|
||||||
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(
|
sqlx::query(
|
||||||
"INSERT INTO messages (id, seq, channel_id, user_id, type, content, metadata, reply_to) \
|
"INSERT INTO messages (id, channel_id, user_id, type, content, metadata, reply_to) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(seq)
|
|
||||||
.bind(&channel_id)
|
.bind(&channel_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(msg_type)
|
.bind(msg_type)
|
||||||
@@ -159,8 +213,7 @@ pub async fn post_message(
|
|||||||
.bind(&metadata_json)
|
.bind(&metadata_json)
|
||||||
.bind(&reply_to)
|
.bind(&reply_to)
|
||||||
.execute(&db)
|
.execute(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Fetch the full message with user
|
// Fetch the full message with user
|
||||||
let row = sqlx::query_as::<_, MessageWithUserRow>(
|
let row = sqlx::query_as::<_, MessageWithUserRow>(
|
||||||
@@ -169,17 +222,15 @@ pub async fn post_message(
|
|||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_one(&db)
|
.fetch_one(&db)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
(StatusCode::CREATED, Json(row.to_api_message()))
|
Ok((StatusCode::CREATED, Json(row.to_api_message())))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Joined row type for message + user ──
|
// ── Joined row type for message + user ──
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct MessageWithUserRow {
|
pub struct MessageWithUserRow {
|
||||||
// message fields
|
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub seq: i64,
|
pub seq: i64,
|
||||||
pub channel_id: String,
|
pub channel_id: String,
|
||||||
@@ -191,7 +242,6 @@ pub struct MessageWithUserRow {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: Option<String>,
|
pub updated_at: Option<String>,
|
||||||
pub deleted_at: Option<String>,
|
pub deleted_at: Option<String>,
|
||||||
// user fields (aliased)
|
|
||||||
pub u_id: String,
|
pub u_id: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
|
|||||||
175
package-lock.json
generated
Normal file
175
package-lock.json
generated
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"name": "apes",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/biome": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"biome": "bin/biome"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/biome"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@biomejs/cli-darwin-arm64": "2.4.9",
|
||||||
|
"@biomejs/cli-darwin-x64": "2.4.9",
|
||||||
|
"@biomejs/cli-linux-arm64": "2.4.9",
|
||||||
|
"@biomejs/cli-linux-arm64-musl": "2.4.9",
|
||||||
|
"@biomejs/cli-linux-x64": "2.4.9",
|
||||||
|
"@biomejs/cli-linux-x64-musl": "2.4.9",
|
||||||
|
"@biomejs/cli-win32-arm64": "2.4.9",
|
||||||
|
"@biomejs/cli-win32-x64": "2.4.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-darwin-x64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-arm64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-x64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-win32-arm64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@biomejs/cli-win32-x64": {
|
||||||
|
"version": "2.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.9.tgz",
|
||||||
|
"integrity": "sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.21.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
ui/colony
Submodule
1
ui/colony
Submodule
Submodule ui/colony added at 1e8367a5bb
Reference in New Issue
Block a user