Implement MCP automation and approvals.
This commit is contained in:
646
crates/bds-core/src/engine/mcp/tools.rs
Normal file
646
crates/bds-core/src/engine/mcp/tools.rs
Normal file
@@ -0,0 +1,646 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Datelike, TimeZone as _, Utc};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::db::DbConnection;
|
||||
use crate::db::queries::{media as media_q, media_translation, post as post_q, post_translation};
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, ProposalKind};
|
||||
|
||||
use super::resources::{post_detail, post_summary};
|
||||
use super::{active_project, create_proposal, optional_string, required_string, string_array};
|
||||
|
||||
const MAX_PAGE_SIZE: usize = 50;
|
||||
|
||||
pub fn list() -> Vec<Value> {
|
||||
[
|
||||
tool(
|
||||
"check_term",
|
||||
"Check Term",
|
||||
"Check whether a term is a category, a tag, or both, with post counts.",
|
||||
object_schema(json!({"term": string_schema("Term to check")}), &["term"]),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"search_posts",
|
||||
"Search Posts",
|
||||
"Full-text and filtered post search with pagination, backlinks, and outgoing links.",
|
||||
post_query_schema(false),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"count_posts",
|
||||
"Count Posts",
|
||||
"Count filtered posts grouped by year, month, tag, category, or status.",
|
||||
count_schema(),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"read_post_by_slug",
|
||||
"Read Post By Slug",
|
||||
"Read full post content and metadata, optionally in a translated language.",
|
||||
object_schema(
|
||||
json!({
|
||||
"slug": string_schema("Post slug"),
|
||||
"language": string_schema("Optional translation language")
|
||||
}),
|
||||
&["slug"],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_post_translations",
|
||||
"Get Post Translations",
|
||||
"List every translation for a post.",
|
||||
id_schema("postId", "Post ID"),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"get_media_translations",
|
||||
"Get Media Translations",
|
||||
"List every translated metadata record for a media item.",
|
||||
id_schema("mediaId", "Media ID"),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"upsert_media_translation",
|
||||
"Propose Media Translation",
|
||||
"Propose translated media metadata for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"mediaId": string_schema("Media ID"),
|
||||
"language": string_schema("Language code"),
|
||||
"title": string_schema("Translated title"),
|
||||
"alt": string_schema("Translated alt text"),
|
||||
"caption": string_schema("Translated caption")
|
||||
}),
|
||||
&["mediaId", "language"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"draft_post",
|
||||
"Draft Post",
|
||||
"Propose a post. No post is created until explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Post title"),
|
||||
"content": string_schema("Markdown body"),
|
||||
"excerpt": string_schema("Excerpt"),
|
||||
"tags": string_array_schema("Tags"),
|
||||
"categories": string_array_schema("Categories"),
|
||||
"author": string_schema("Author"),
|
||||
"language": string_schema("Language")
|
||||
}),
|
||||
&["title", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_script",
|
||||
"Propose Script",
|
||||
"Validate and propose a Lua script for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Script title"),
|
||||
"kind": {"type":"string","enum":["macro","utility","transform"]},
|
||||
"content": string_schema("Lua source"),
|
||||
"entrypoint": string_schema("Entrypoint function")
|
||||
}),
|
||||
&["title", "kind", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_template",
|
||||
"Propose Template",
|
||||
"Validate and propose a Liquid template for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"title": string_schema("Template title"),
|
||||
"kind": {"type":"string","enum":["post","list","not-found","partial"]},
|
||||
"content": string_schema("Liquid source")
|
||||
}),
|
||||
&["title", "kind", "content"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_media_metadata",
|
||||
"Propose Media Metadata",
|
||||
"Propose media metadata changes for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"mediaId": string_schema("Media ID"),
|
||||
"title": nullable_string_schema("Title"),
|
||||
"alt": nullable_string_schema("Alt text"),
|
||||
"caption": nullable_string_schema("Caption"),
|
||||
"tags": string_array_schema("Tags")
|
||||
}),
|
||||
&["mediaId"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"propose_post_metadata",
|
||||
"Propose Post Metadata",
|
||||
"Propose post metadata changes for explicit desktop approval.",
|
||||
object_schema(
|
||||
json!({
|
||||
"postId": string_schema("Post ID"),
|
||||
"title": string_schema("Title"),
|
||||
"excerpt": nullable_string_schema("Excerpt"),
|
||||
"tags": string_array_schema("Tags"),
|
||||
"categories": string_array_schema("Categories")
|
||||
}),
|
||||
&["postId"],
|
||||
),
|
||||
false,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn call(conn: &DbConnection, name: &str, params: Value) -> EngineResult<Value> {
|
||||
if !params.is_object() {
|
||||
return Err(EngineError::Validation(
|
||||
"tool arguments must be an object".into(),
|
||||
));
|
||||
}
|
||||
match name {
|
||||
"check_term" => check_term(conn, ¶ms),
|
||||
"search_posts" => search_posts(conn, ¶ms),
|
||||
"count_posts" => count_posts(conn, ¶ms),
|
||||
"read_post_by_slug" => read_post_by_slug(conn, ¶ms),
|
||||
"get_post_translations" => get_post_translations(conn, ¶ms),
|
||||
"get_media_translations" => get_media_translations(conn, ¶ms),
|
||||
"upsert_media_translation" => propose_media_translation(conn, ¶ms),
|
||||
"draft_post" => propose(conn, ProposalKind::DraftPost, None, ¶ms),
|
||||
"propose_script" => propose_script(conn, ¶ms),
|
||||
"propose_template" => propose_template(conn, ¶ms),
|
||||
"propose_media_metadata" => propose_media_metadata(conn, ¶ms),
|
||||
"propose_post_metadata" => propose_post_metadata(conn, ¶ms),
|
||||
_ => Err(EngineError::NotFound(format!("MCP tool {name}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool(name: &str, title: &str, description: &str, schema: Value, read_only: bool) -> Value {
|
||||
json!({
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"inputSchema": schema,
|
||||
"annotations": {
|
||||
"readOnlyHint": read_only,
|
||||
"destructiveHint": false,
|
||||
"openWorldHint": false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn string_schema(description: &str) -> Value {
|
||||
json!({"type":"string","description":description})
|
||||
}
|
||||
|
||||
fn nullable_string_schema(description: &str) -> Value {
|
||||
json!({"type":["string","null"],"description":description})
|
||||
}
|
||||
|
||||
fn string_array_schema(description: &str) -> Value {
|
||||
json!({"type":"array","items":{"type":"string"},"description":description})
|
||||
}
|
||||
|
||||
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
||||
let mut schema = json!({
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false
|
||||
});
|
||||
if !required.is_empty() {
|
||||
schema["required"] = serde_json::to_value(required).unwrap_or_default();
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
fn id_schema(field: &str, description: &str) -> Value {
|
||||
let mut properties = Map::new();
|
||||
properties.insert(field.into(), string_schema(description));
|
||||
object_schema(Value::Object(properties), &[field])
|
||||
}
|
||||
|
||||
fn post_query_schema(query_required: bool) -> Value {
|
||||
object_schema(
|
||||
json!({
|
||||
"query": string_schema("Full-text query"),
|
||||
"category": string_schema("Category filter"),
|
||||
"tags": string_array_schema("All required tags"),
|
||||
"language": string_schema("Available language"),
|
||||
"missingTranslationLanguage": string_schema("Missing translation language"),
|
||||
"year": {"type":"integer"},
|
||||
"month": {"type":"integer","minimum":1,"maximum":12},
|
||||
"status": {"type":"string","enum":["draft","published","archived"]},
|
||||
"offset": {"type":"integer","minimum":0},
|
||||
"limit": {"type":"integer","minimum":1,"maximum":MAX_PAGE_SIZE}
|
||||
}),
|
||||
if query_required { &["query"] } else { &[] },
|
||||
)
|
||||
}
|
||||
|
||||
fn count_schema() -> Value {
|
||||
let mut schema = post_query_schema(false);
|
||||
schema["properties"]["groupBy"] = json!({"type":"array","items":{"type":"string","enum":["year","month","tag","category","status"]},"minItems":1});
|
||||
schema["required"] = json!(["groupBy"]);
|
||||
schema
|
||||
}
|
||||
|
||||
fn check_term(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let term = required_string(params, "term")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let posts = post_q::list_posts_by_project(conn, &project.id)?;
|
||||
let normalized = term.to_lowercase();
|
||||
let tag_count = posts
|
||||
.iter()
|
||||
.filter(|post| post.tags.iter().any(|tag| tag.to_lowercase() == normalized))
|
||||
.count();
|
||||
let category_count = posts
|
||||
.iter()
|
||||
.filter(|post| {
|
||||
post.categories
|
||||
.iter()
|
||||
.any(|category| category.to_lowercase() == normalized)
|
||||
})
|
||||
.count();
|
||||
Ok(json!({
|
||||
"is_category": category_count > 0,
|
||||
"category_post_count": category_count,
|
||||
"is_tag": tag_count > 0,
|
||||
"tag_post_count": tag_count
|
||||
}))
|
||||
}
|
||||
|
||||
fn filtered_posts(conn: &DbConnection, params: &Value) -> EngineResult<Vec<Post>> {
|
||||
let (project, _) = active_project(conn)?;
|
||||
let mut posts = post_q::list_posts_by_project(conn, &project.id)?;
|
||||
let query = optional_string(params, "query")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let fts_matches = if query.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let language = optional_string(params, "language").unwrap_or("en");
|
||||
Some(
|
||||
crate::db::fts::search_posts(conn, &query, language)?
|
||||
.into_iter()
|
||||
.collect::<std::collections::HashSet<_>>(),
|
||||
)
|
||||
};
|
||||
let category = optional_string(params, "category").map(str::to_lowercase);
|
||||
let tags = string_array(params, "tags")
|
||||
.into_iter()
|
||||
.map(|tag| tag.to_lowercase())
|
||||
.collect::<Vec<_>>();
|
||||
let language = optional_string(params, "language").map(str::to_lowercase);
|
||||
let missing_language =
|
||||
optional_string(params, "missingTranslationLanguage").map(str::to_lowercase);
|
||||
let status = optional_string(params, "status");
|
||||
let year = integer(params, "year")?.map(|value| value as i32);
|
||||
let month = integer(params, "month")?.map(|value| value as u32);
|
||||
if month.is_some() && year.is_none() {
|
||||
return Err(EngineError::Validation("month requires year".into()));
|
||||
}
|
||||
posts.retain(|post| {
|
||||
if fts_matches
|
||||
.as_ref()
|
||||
.is_some_and(|matches| !matches.contains(&post.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if category.as_ref().is_some_and(|wanted| {
|
||||
!post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase() == *wanted)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if tags.iter().any(|wanted| {
|
||||
!post
|
||||
.tags
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase() == *wanted)
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if status.is_some_and(|wanted| post.status.as_str() != wanted) {
|
||||
return false;
|
||||
}
|
||||
if let Some(wanted) = &language {
|
||||
let canonical = post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
|
||||
let translated =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted)
|
||||
.is_ok();
|
||||
if !canonical && !translated {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(wanted) = &missing_language {
|
||||
let canonical = post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(wanted));
|
||||
let translated =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, wanted)
|
||||
.is_ok();
|
||||
if canonical || translated {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(wanted_year) = year {
|
||||
let Some(date) = Utc.timestamp_millis_opt(post.created_at).single() else {
|
||||
return false;
|
||||
};
|
||||
if date.year() != wanted_year || month.is_some_and(|wanted| date.month() != wanted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
fn search_posts(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let posts = filtered_posts(conn, params)?;
|
||||
let total = posts.len();
|
||||
let offset = unsigned(params, "offset")?.unwrap_or(0);
|
||||
let limit = unsigned(params, "limit")?.unwrap_or(MAX_PAGE_SIZE);
|
||||
if limit == 0 || limit > MAX_PAGE_SIZE {
|
||||
return Err(EngineError::Validation(format!(
|
||||
"limit must be between 1 and {MAX_PAGE_SIZE}"
|
||||
)));
|
||||
}
|
||||
let posts = posts
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|post| post_summary(conn, &post))
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(json!({
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"hasMore": offset.saturating_add(limit) < total,
|
||||
"posts": posts
|
||||
}))
|
||||
}
|
||||
|
||||
fn count_posts(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let group_by = string_array(params, "groupBy");
|
||||
if group_by.is_empty()
|
||||
|| group_by.iter().any(|dimension| {
|
||||
!["year", "month", "tag", "category", "status"].contains(&dimension.as_str())
|
||||
})
|
||||
{
|
||||
return Err(EngineError::Validation("invalid groupBy".into()));
|
||||
}
|
||||
let posts = filtered_posts(conn, params)?;
|
||||
let total = posts.len();
|
||||
let mut counts = BTreeMap::<String, (Map<String, Value>, usize)>::new();
|
||||
for post in posts {
|
||||
for row in group_rows(&post, &group_by) {
|
||||
let key = serde_json::to_string(&row)?;
|
||||
counts
|
||||
.entry(key)
|
||||
.and_modify(|(_, count)| *count += 1)
|
||||
.or_insert((row, 1));
|
||||
}
|
||||
}
|
||||
let groups = counts
|
||||
.into_values()
|
||||
.map(|(mut row, count)| {
|
||||
row.insert("count".into(), json!(count));
|
||||
Value::Object(row)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({"groups": groups, "totalPosts": total}))
|
||||
}
|
||||
|
||||
fn group_rows(post: &Post, dimensions: &[String]) -> Vec<Map<String, Value>> {
|
||||
let Some((dimension, rest)) = dimensions.split_first() else {
|
||||
return vec![Map::new()];
|
||||
};
|
||||
let values = match dimension.as_str() {
|
||||
"year" => Utc
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|date| vec![json!(date.year())])
|
||||
.unwrap_or_else(|| vec![Value::Null]),
|
||||
"month" => Utc
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|date| vec![json!(date.month())])
|
||||
.unwrap_or_else(|| vec![Value::Null]),
|
||||
"tag" => values_or_null(&post.tags),
|
||||
"category" => values_or_null(&post.categories),
|
||||
"status" => vec![json!(post.status.as_str())],
|
||||
_ => vec![Value::Null],
|
||||
};
|
||||
values
|
||||
.into_iter()
|
||||
.flat_map(|value| {
|
||||
group_rows(post, rest).into_iter().map({
|
||||
let dimension = dimension.clone();
|
||||
move |mut row| {
|
||||
row.insert(dimension.clone(), value.clone());
|
||||
row
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn values_or_null(values: &[String]) -> Vec<Value> {
|
||||
if values.is_empty() {
|
||||
vec![Value::Null]
|
||||
} else {
|
||||
values.iter().map(|value| json!(value)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn read_post_by_slug(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let slug = required_string(params, "slug")?;
|
||||
let (project, data_dir) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_project_and_slug(conn, &project.id, slug)
|
||||
.map_err(|_| EngineError::NotFound(format!("post slug {slug}")))?;
|
||||
let Some(language) = optional_string(params, "language") else {
|
||||
return Ok(json!({"post": post_detail(conn, &data_dir, &post)?}));
|
||||
};
|
||||
if post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|canonical| canonical.eq_ignore_ascii_case(language))
|
||||
{
|
||||
return Ok(json!({"post": post_detail(conn, &data_dir, &post)?}));
|
||||
}
|
||||
let translation =
|
||||
post_translation::get_post_translation_by_post_and_language(conn, &post.id, language)
|
||||
.map_err(|_| EngineError::NotFound(format!("post translation {language}")))?;
|
||||
let mut detail = post_detail(conn, &data_dir, &post)?;
|
||||
detail["title"] = json!(translation.title);
|
||||
detail["excerpt"] = json!(translation.excerpt);
|
||||
detail["content"] = json!(translation_content(&data_dir, &translation));
|
||||
detail["language"] = json!(translation.language);
|
||||
detail["canonicalLanguage"] = json!(post.language);
|
||||
Ok(json!({"post": detail}))
|
||||
}
|
||||
|
||||
fn translation_content(
|
||||
data_dir: &std::path::Path,
|
||||
translation: &crate::model::PostTranslation,
|
||||
) -> String {
|
||||
if let Some(content) = &translation.content {
|
||||
return content.clone();
|
||||
}
|
||||
std::fs::read_to_string(data_dir.join(&translation.file_path))
|
||||
.ok()
|
||||
.and_then(|source| {
|
||||
crate::util::frontmatter::read_translation_file(&source)
|
||||
.ok()
|
||||
.map(|(_, body)| body)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_post_translations(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "postId")?;
|
||||
let (project, data_dir) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {id}")))?;
|
||||
if post.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("post {id}")));
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, id)?
|
||||
.into_iter()
|
||||
.map(|translation| {
|
||||
let mut value = serde_json::to_value(&translation)?;
|
||||
value["content"] = json!(translation_content(&data_dir, &translation));
|
||||
Ok(value)
|
||||
})
|
||||
.collect::<EngineResult<Vec<_>>>()?;
|
||||
Ok(json!({"translations": translations}))
|
||||
}
|
||||
|
||||
fn get_media_translations(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
Ok(json!({
|
||||
"translations": media_translation::list_media_translations_by_media(conn, id)?
|
||||
}))
|
||||
}
|
||||
|
||||
fn propose_media_translation(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
required_string(params, "language")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
propose(
|
||||
conn,
|
||||
ProposalKind::ProposeMediaTranslation,
|
||||
Some(id),
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
fn propose_script(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
required_string(params, "title")?;
|
||||
let content = required_string(params, "content")?;
|
||||
required_string(params, "kind")?
|
||||
.parse::<crate::model::ScriptKind>()
|
||||
.map_err(EngineError::Validation)?;
|
||||
crate::engine::script::validate_script_syntax(content).map_err(EngineError::Validation)?;
|
||||
propose(conn, ProposalKind::ProposeScript, None, params)
|
||||
}
|
||||
|
||||
fn propose_template(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
required_string(params, "title")?;
|
||||
let content = required_string(params, "content")?;
|
||||
required_string(params, "kind")?
|
||||
.parse::<crate::model::TemplateKind>()
|
||||
.map_err(EngineError::Validation)?;
|
||||
crate::engine::template::validate_template(content).map_err(EngineError::Validation)?;
|
||||
propose(conn, ProposalKind::ProposeTemplate, None, params)
|
||||
}
|
||||
|
||||
fn propose_media_metadata(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "mediaId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let media = media_q::get_media_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("media {id}")))?;
|
||||
if media.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("media {id}")));
|
||||
}
|
||||
propose(conn, ProposalKind::ProposeMediaMetadata, Some(id), params)
|
||||
}
|
||||
|
||||
fn propose_post_metadata(conn: &DbConnection, params: &Value) -> EngineResult<Value> {
|
||||
let id = required_string(params, "postId")?;
|
||||
let (project, _) = active_project(conn)?;
|
||||
let post = post_q::get_post_by_id(conn, id)
|
||||
.map_err(|_| EngineError::NotFound(format!("post {id}")))?;
|
||||
if post.project_id != project.id {
|
||||
return Err(EngineError::NotFound(format!("post {id}")));
|
||||
}
|
||||
propose(conn, ProposalKind::ProposePostMetadata, Some(id), params)
|
||||
}
|
||||
|
||||
fn propose(
|
||||
conn: &DbConnection,
|
||||
kind: ProposalKind,
|
||||
entity_id: Option<&str>,
|
||||
params: &Value,
|
||||
) -> EngineResult<Value> {
|
||||
if kind == ProposalKind::DraftPost {
|
||||
required_string(params, "title")?;
|
||||
required_string(params, "content")?;
|
||||
}
|
||||
let (project, _) = active_project(conn)?;
|
||||
let proposal = create_proposal(conn, kind, &project.id, entity_id, params)?;
|
||||
Ok(json!({
|
||||
"proposalId": proposal.id,
|
||||
"status": proposal.status,
|
||||
"expiresAt": proposal.expires_at,
|
||||
"message": "Pending explicit approval in RuDS Settings"
|
||||
}))
|
||||
}
|
||||
|
||||
fn integer(value: &Value, key: &str) -> EngineResult<Option<i64>> {
|
||||
match value.get(key) {
|
||||
None => Ok(None),
|
||||
Some(value) => value
|
||||
.as_i64()
|
||||
.map(Some)
|
||||
.ok_or_else(|| EngineError::Validation(format!("{key} must be an integer"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsigned(value: &Value, key: &str) -> EngineResult<Option<usize>> {
|
||||
integer(value, key)?.map_or(Ok(None), |value| {
|
||||
usize::try_from(value)
|
||||
.map(Some)
|
||||
.map_err(|_| EngineError::Validation(format!("{key} cannot be negative")))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user