feat: add Theme model and DB queries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
cad61fadfc
commit
467ad550a5
@ -0,0 +1,106 @@
|
|||||||
|
//! Database queries for the `themes` table.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
use crate::models::theme::Theme;
|
||||||
|
|
||||||
|
pub async fn list_for_user(pool: &PgPool, user_id: Uuid) -> Result<Vec<Theme>, AppError> {
|
||||||
|
let themes = sqlx::query_as::<_, Theme>(
|
||||||
|
"SELECT * FROM themes WHERE user_id = $1 ORDER BY created_at ASC"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(themes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_id(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<Option<Theme>, AppError> {
|
||||||
|
let theme = sqlx::query_as::<_, Theme>(
|
||||||
|
"SELECT * FROM themes WHERE id = $1 AND user_id = $2"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
theme: &str,
|
||||||
|
categories: &serde_json::Value,
|
||||||
|
max_items_per_category: i32,
|
||||||
|
max_age_days: i32,
|
||||||
|
summary_length: i32,
|
||||||
|
) -> Result<Theme, AppError> {
|
||||||
|
let row = sqlx::query_as::<_, Theme>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO themes (user_id, name, theme, categories, max_items_per_category, max_age_days, summary_length)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(theme)
|
||||||
|
.bind(categories)
|
||||||
|
.bind(max_items_per_category)
|
||||||
|
.bind(max_age_days)
|
||||||
|
.bind(summary_length)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
name: Option<&str>,
|
||||||
|
theme: Option<&str>,
|
||||||
|
categories: Option<&serde_json::Value>,
|
||||||
|
max_items_per_category: Option<i32>,
|
||||||
|
max_age_days: Option<i32>,
|
||||||
|
summary_length: Option<i32>,
|
||||||
|
) -> Result<Option<Theme>, AppError> {
|
||||||
|
let row = sqlx::query_as::<_, Theme>(
|
||||||
|
r#"
|
||||||
|
UPDATE themes SET
|
||||||
|
name = COALESCE($3, name),
|
||||||
|
theme = COALESCE($4, theme),
|
||||||
|
categories = COALESCE($5, categories),
|
||||||
|
max_items_per_category = COALESCE($6, max_items_per_category),
|
||||||
|
max_age_days = COALESCE($7, max_age_days),
|
||||||
|
summary_length = COALESCE($8, summary_length),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
RETURNING *
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(theme)
|
||||||
|
.bind(categories)
|
||||||
|
.bind(max_items_per_category)
|
||||||
|
.bind(max_age_days)
|
||||||
|
.bind(summary_length)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<bool, AppError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
"DELETE FROM themes WHERE id = $1 AND user_id = $2"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
//! Theme model — per-topic content settings.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// A user's theme with content settings.
|
||||||
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub theme: String,
|
||||||
|
pub categories: serde_json::Value,
|
||||||
|
pub max_items_per_category: i32,
|
||||||
|
pub max_age_days: i32,
|
||||||
|
pub summary_length: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for creating a theme.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateThemeRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub theme: String,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub max_items_per_category: Option<i32>,
|
||||||
|
pub max_age_days: Option<i32>,
|
||||||
|
pub summary_length: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateThemeRequest {
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err("Theme name cannot be empty".into());
|
||||||
|
}
|
||||||
|
if self.name.len() > 200 {
|
||||||
|
return Err("Theme name must be at most 200 characters".into());
|
||||||
|
}
|
||||||
|
if self.theme.trim().is_empty() {
|
||||||
|
return Err("Theme search topic cannot be empty".into());
|
||||||
|
}
|
||||||
|
if self.categories.is_empty() {
|
||||||
|
return Err("Categories cannot be empty".into());
|
||||||
|
}
|
||||||
|
if self.categories.len() > 20 {
|
||||||
|
return Err("At most 20 categories are allowed".into());
|
||||||
|
}
|
||||||
|
for (i, cat) in self.categories.iter().enumerate() {
|
||||||
|
if cat.trim().is_empty() {
|
||||||
|
return Err(format!("Category at index {} cannot be empty", i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max) = self.max_items_per_category {
|
||||||
|
if !(1..=50).contains(&max) {
|
||||||
|
return Err("max_items_per_category must be between 1 and 50".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(days) = self.max_age_days {
|
||||||
|
if !(1..=365).contains(&days) {
|
||||||
|
return Err("max_age_days must be between 1 and 365".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(sl) = self.summary_length {
|
||||||
|
if !(1..=3).contains(&sl) {
|
||||||
|
return Err("summary_length must be between 1 and 3".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request body for updating a theme.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateThemeRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub theme: Option<String>,
|
||||||
|
pub categories: Option<Vec<String>>,
|
||||||
|
pub max_items_per_category: Option<i32>,
|
||||||
|
pub max_age_days: Option<i32>,
|
||||||
|
pub summary_length: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response shape for theme API.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ThemeResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub theme: String,
|
||||||
|
pub categories: Vec<String>,
|
||||||
|
pub max_items_per_category: i32,
|
||||||
|
pub max_age_days: i32,
|
||||||
|
pub summary_length: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Theme> for ThemeResponse {
|
||||||
|
type Error = AppError;
|
||||||
|
fn try_from(t: Theme) -> Result<Self, Self::Error> {
|
||||||
|
let categories: Vec<String> = serde_json::from_value(t.categories).unwrap_or_default();
|
||||||
|
Ok(Self {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
theme: t.theme,
|
||||||
|
categories,
|
||||||
|
max_items_per_category: t.max_items_per_category,
|
||||||
|
max_age_days: t.max_age_days,
|
||||||
|
summary_length: t.summary_length,
|
||||||
|
created_at: t.created_at,
|
||||||
|
updated_at: t.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue