452 lines
17 KiB
Rust
452 lines
17 KiB
Rust
use actix_web::{HttpResponse, Responder, Scope, delete, get, post, put, web};
|
|
use sea_orm::{
|
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::app_data::AppData;
|
|
use crate::entity::sea_orm_active_enums::{RecurrenceType, TargetType};
|
|
use crate::entity::{recurrence_rule, reminder_offset, reminder_task, reminder_task_recipient};
|
|
use crate::error::ApiError;
|
|
use crate::middleware::auth::AuthUser;
|
|
use crate::timer::WorkerCommand;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct RecurrenceRuleInput {
|
|
pub r#type: String,
|
|
pub interval: Option<i32>,
|
|
pub by_weekday: Option<i32>,
|
|
pub by_monthday: Option<i32>,
|
|
pub timezone: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct OffsetInput {
|
|
pub offset_minutes: i32,
|
|
pub channel_inapp: Option<bool>,
|
|
pub channel_bark: Option<bool>,
|
|
/// Custom title for Bark push notification
|
|
pub bark_title: Option<String>,
|
|
/// Custom subtitle for Bark push notification
|
|
pub bark_subtitle: Option<String>,
|
|
/// Markdown content for Bark push (overrides body if set)
|
|
pub bark_body_markdown: Option<String>,
|
|
/// Alert level: active, timeSensitive, passive, critical
|
|
pub bark_level: Option<String>,
|
|
/// Custom icon URL for Bark push
|
|
pub bark_icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ReminderTaskInput {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub due_at: chrono::DateTime<chrono::Utc>,
|
|
pub recipient_ids: Vec<Uuid>,
|
|
pub recurrence_rule: Option<RecurrenceRuleInput>,
|
|
pub offsets: Option<Vec<OffsetInput>>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OkResponse {
|
|
pub ok: bool,
|
|
}
|
|
|
|
#[get("")]
|
|
async fn list_tasks(
|
|
app_data: web::Data<AppData>,
|
|
auth: AuthUser,
|
|
) -> Result<impl Responder, ApiError> {
|
|
let items = reminder_task::Entity::find()
|
|
.filter(reminder_task::Column::CreatorId.eq(auth.user_id))
|
|
.order_by_desc(reminder_task::Column::DueAt)
|
|
.find_also_related(recurrence_rule::Entity)
|
|
.all(&app_data.db)
|
|
.await?;
|
|
|
|
let task_ids: Vec<Uuid> = items.iter().map(|(t, _)| t.id).collect();
|
|
|
|
let recipients = reminder_task_recipient::Entity::find()
|
|
.filter(reminder_task_recipient::Column::TaskId.is_in(task_ids.clone()))
|
|
.all(&app_data.db)
|
|
.await?;
|
|
|
|
let offsets = reminder_offset::Entity::find()
|
|
.filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask))
|
|
.filter(reminder_offset::Column::TargetId.is_in(task_ids))
|
|
.all(&app_data.db)
|
|
.await?;
|
|
|
|
let result: Vec<serde_json::Value> = items
|
|
.into_iter()
|
|
.map(|(t, rule)| {
|
|
let task_recipients: Vec<_> = recipients
|
|
.iter()
|
|
.filter(|r| r.task_id == t.id)
|
|
.cloned()
|
|
.collect();
|
|
let task_offsets: Vec<_> = offsets
|
|
.iter()
|
|
.filter(|o| o.target_id == t.id)
|
|
.cloned()
|
|
.collect();
|
|
serde_json::json!({
|
|
"id": t.id,
|
|
"creatorId": t.creator_id,
|
|
"title": t.title,
|
|
"description": t.description,
|
|
"dueAt": t.due_at,
|
|
"recurrenceRuleId": t.recurrence_rule_id,
|
|
"createdAt": t.created_at,
|
|
"updatedAt": t.updated_at,
|
|
"recurrenceRule": rule,
|
|
"recipients": task_recipients,
|
|
"offsets": task_offsets,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
Ok(HttpResponse::Ok().json(result))
|
|
}
|
|
|
|
#[post("")]
|
|
async fn create_task(
|
|
app_data: web::Data<AppData>,
|
|
auth: AuthUser,
|
|
body: web::Json<ReminderTaskInput>,
|
|
) -> Result<impl Responder, ApiError> {
|
|
if body.title.is_empty() {
|
|
return Err(ApiError::BadRequest("Invalid payload".to_string()));
|
|
}
|
|
|
|
let now = chrono::Utc::now().fixed_offset();
|
|
let body = body.into_inner();
|
|
let user_id = auth.user_id;
|
|
|
|
let result = app_data
|
|
.db
|
|
.transaction::<_, reminder_task::Model, ApiError>(|txn| {
|
|
Box::pin(async move {
|
|
// Create recurrence rule if provided
|
|
let rule_id = if let Some(rule_input) = body.recurrence_rule {
|
|
let rule = recurrence_rule::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
r#type: Set(parse_recurrence_type(&rule_input.r#type)?),
|
|
interval: Set(rule_input.interval.unwrap_or(1)),
|
|
by_weekday: Set(rule_input.by_weekday),
|
|
by_monthday: Set(rule_input.by_monthday),
|
|
timezone: Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string())),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
};
|
|
let created = rule.insert(txn).await?;
|
|
Some(created.id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Create task
|
|
let new_task = reminder_task::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
creator_id: Set(user_id),
|
|
title: Set(body.title),
|
|
description: Set(body.description),
|
|
due_at: Set(body.due_at.fixed_offset()),
|
|
recurrence_rule_id: Set(rule_id),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
};
|
|
let created_task = new_task.insert(txn).await?;
|
|
|
|
// Create recipients
|
|
for recipient_id in body.recipient_ids {
|
|
let new_recipient = reminder_task_recipient::ActiveModel {
|
|
task_id: Set(created_task.id),
|
|
user_id: Set(recipient_id),
|
|
};
|
|
new_recipient.insert(txn).await?;
|
|
}
|
|
|
|
// Create offsets
|
|
if let Some(offsets) = body.offsets {
|
|
for offset in offsets {
|
|
let new_offset = reminder_offset::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
target_type: Set(TargetType::ReminderTask),
|
|
target_id: Set(created_task.id),
|
|
offset_minutes: Set(offset.offset_minutes),
|
|
channel_inapp: Set(offset.channel_inapp.unwrap_or(true)),
|
|
channel_bark: Set(offset.channel_bark.unwrap_or(false)),
|
|
created_at: Set(now),
|
|
bark_title: Set(offset.bark_title),
|
|
bark_subtitle: Set(offset.bark_subtitle),
|
|
bark_body_markdown: Set(offset.bark_body_markdown),
|
|
bark_level: Set(offset.bark_level),
|
|
bark_icon: Set(offset.bark_icon),
|
|
};
|
|
new_offset.insert(txn).await?;
|
|
}
|
|
}
|
|
|
|
Ok(created_task)
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|e| match e {
|
|
sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()),
|
|
sea_orm::TransactionError::Transaction(e) => e,
|
|
})?;
|
|
|
|
// 触发通知生成
|
|
let _ = app_data
|
|
.send_worker_command(WorkerCommand::GenerateNotifications {
|
|
target_type: TargetType::ReminderTask,
|
|
target_id: result.id,
|
|
})
|
|
.await;
|
|
|
|
Ok(HttpResponse::Ok().json(result))
|
|
}
|
|
|
|
#[get("/{id}")]
|
|
async fn get_task(
|
|
app_data: web::Data<AppData>,
|
|
auth: AuthUser,
|
|
path: web::Path<Uuid>,
|
|
) -> Result<impl Responder, ApiError> {
|
|
let id = path.into_inner();
|
|
|
|
let (t, rule) = reminder_task::Entity::find_by_id(id)
|
|
.filter(reminder_task::Column::CreatorId.eq(auth.user_id))
|
|
.find_also_related(recurrence_rule::Entity)
|
|
.one(&app_data.db)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound("Not found".to_string()))?;
|
|
|
|
let recipients = reminder_task_recipient::Entity::find()
|
|
.filter(reminder_task_recipient::Column::TaskId.eq(t.id))
|
|
.all(&app_data.db)
|
|
.await?;
|
|
|
|
let offsets = reminder_offset::Entity::find()
|
|
.filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask))
|
|
.filter(reminder_offset::Column::TargetId.eq(t.id))
|
|
.all(&app_data.db)
|
|
.await?;
|
|
|
|
let result = serde_json::json!({
|
|
"id": t.id,
|
|
"creatorId": t.creator_id,
|
|
"title": t.title,
|
|
"description": t.description,
|
|
"dueAt": t.due_at,
|
|
"recurrenceRuleId": t.recurrence_rule_id,
|
|
"createdAt": t.created_at,
|
|
"updatedAt": t.updated_at,
|
|
"recurrenceRule": rule,
|
|
"recipients": recipients,
|
|
"offsets": offsets,
|
|
});
|
|
|
|
Ok(HttpResponse::Ok().json(result))
|
|
}
|
|
|
|
#[put("/{id}")]
|
|
async fn update_task(
|
|
app_data: web::Data<AppData>,
|
|
auth: AuthUser,
|
|
path: web::Path<Uuid>,
|
|
body: web::Json<ReminderTaskInput>,
|
|
) -> Result<impl Responder, ApiError> {
|
|
let id = path.into_inner();
|
|
|
|
if body.title.is_empty() {
|
|
return Err(ApiError::BadRequest("Invalid payload".to_string()));
|
|
}
|
|
|
|
let now = chrono::Utc::now().fixed_offset();
|
|
let body = body.into_inner();
|
|
let user_id = auth.user_id;
|
|
|
|
let result = app_data
|
|
.db
|
|
.transaction::<_, reminder_task::Model, ApiError>(|txn| {
|
|
Box::pin(async move {
|
|
let existing = reminder_task::Entity::find_by_id(id)
|
|
.filter(reminder_task::Column::CreatorId.eq(user_id))
|
|
.one(txn)
|
|
.await?
|
|
.ok_or_else(|| ApiError::NotFound("Not found".to_string()))?;
|
|
|
|
// Handle recurrence rule
|
|
let mut rule_id = existing.recurrence_rule_id;
|
|
if let Some(rule_input) = body.recurrence_rule {
|
|
if let Some(existing_rule_id) = rule_id {
|
|
// Update existing rule
|
|
let mut rule: recurrence_rule::ActiveModel =
|
|
recurrence_rule::Entity::find_by_id(existing_rule_id)
|
|
.one(txn)
|
|
.await?
|
|
.ok_or_else(|| ApiError::Internal("Rule not found".to_string()))?
|
|
.into();
|
|
rule.r#type = Set(parse_recurrence_type(&rule_input.r#type)?);
|
|
rule.interval = Set(rule_input.interval.unwrap_or(1));
|
|
rule.by_weekday = Set(rule_input.by_weekday);
|
|
rule.by_monthday = Set(rule_input.by_monthday);
|
|
rule.timezone =
|
|
Set(rule_input.timezone.unwrap_or("Asia/Shanghai".to_string()));
|
|
rule.updated_at = Set(now);
|
|
rule.update(txn).await?;
|
|
} else {
|
|
// Create new rule
|
|
let rule = recurrence_rule::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
r#type: Set(parse_recurrence_type(&rule_input.r#type)?),
|
|
interval: Set(rule_input.interval.unwrap_or(1)),
|
|
by_weekday: Set(rule_input.by_weekday),
|
|
by_monthday: Set(rule_input.by_monthday),
|
|
timezone: Set(rule_input
|
|
.timezone
|
|
.unwrap_or("Asia/Shanghai".to_string())),
|
|
created_at: Set(now),
|
|
updated_at: Set(now),
|
|
};
|
|
let created = rule.insert(txn).await?;
|
|
rule_id = Some(created.id);
|
|
}
|
|
} else if let Some(existing_rule_id) = rule_id {
|
|
// Delete existing rule
|
|
recurrence_rule::Entity::delete_by_id(existing_rule_id)
|
|
.exec(txn)
|
|
.await?;
|
|
rule_id = None;
|
|
}
|
|
|
|
// Delete existing recipients and create new ones
|
|
reminder_task_recipient::Entity::delete_many()
|
|
.filter(reminder_task_recipient::Column::TaskId.eq(id))
|
|
.exec(txn)
|
|
.await?;
|
|
|
|
for recipient_id in body.recipient_ids {
|
|
let new_recipient = reminder_task_recipient::ActiveModel {
|
|
task_id: Set(id),
|
|
user_id: Set(recipient_id),
|
|
};
|
|
new_recipient.insert(txn).await?;
|
|
}
|
|
|
|
// Delete existing offsets and create new ones
|
|
reminder_offset::Entity::delete_many()
|
|
.filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask))
|
|
.filter(reminder_offset::Column::TargetId.eq(id))
|
|
.exec(txn)
|
|
.await?;
|
|
|
|
if let Some(offsets) = body.offsets {
|
|
for offset in offsets {
|
|
let new_offset = reminder_offset::ActiveModel {
|
|
id: Set(Uuid::new_v4()),
|
|
target_type: Set(TargetType::ReminderTask),
|
|
target_id: Set(id),
|
|
offset_minutes: Set(offset.offset_minutes),
|
|
channel_inapp: Set(offset.channel_inapp.unwrap_or(true)),
|
|
channel_bark: Set(offset.channel_bark.unwrap_or(false)),
|
|
created_at: Set(now),
|
|
bark_title: Set(offset.bark_title),
|
|
bark_subtitle: Set(offset.bark_subtitle),
|
|
bark_body_markdown: Set(offset.bark_body_markdown),
|
|
bark_level: Set(offset.bark_level),
|
|
bark_icon: Set(offset.bark_icon),
|
|
};
|
|
new_offset.insert(txn).await?;
|
|
}
|
|
}
|
|
|
|
// Update task
|
|
let mut active: reminder_task::ActiveModel = existing.into();
|
|
active.title = Set(body.title);
|
|
active.description = Set(body.description);
|
|
active.due_at = Set(body.due_at.fixed_offset());
|
|
active.recurrence_rule_id = Set(rule_id);
|
|
active.updated_at = Set(now);
|
|
let updated = active.update(txn).await?;
|
|
|
|
Ok(updated)
|
|
})
|
|
})
|
|
.await
|
|
.map_err(|e| match e {
|
|
sea_orm::TransactionError::Connection(e) => ApiError::Internal(e.to_string()),
|
|
sea_orm::TransactionError::Transaction(e) => e,
|
|
})?;
|
|
|
|
// 触发通知重新生成
|
|
let _ = app_data
|
|
.send_worker_command(WorkerCommand::GenerateNotifications {
|
|
target_type: TargetType::ReminderTask,
|
|
target_id: result.id,
|
|
})
|
|
.await;
|
|
|
|
Ok(HttpResponse::Ok().json(result))
|
|
}
|
|
|
|
#[delete("/{id}")]
|
|
async fn delete_task(
|
|
app_data: web::Data<AppData>,
|
|
auth: AuthUser,
|
|
path: web::Path<Uuid>,
|
|
) -> Result<impl Responder, ApiError> {
|
|
let id = path.into_inner();
|
|
|
|
let result = reminder_task::Entity::delete_many()
|
|
.filter(reminder_task::Column::Id.eq(id))
|
|
.filter(reminder_task::Column::CreatorId.eq(auth.user_id))
|
|
.exec(&app_data.db)
|
|
.await?;
|
|
|
|
if result.rows_affected == 0 {
|
|
return Err(ApiError::NotFound("Not found".to_string()));
|
|
}
|
|
|
|
// Delete offsets
|
|
reminder_offset::Entity::delete_many()
|
|
.filter(reminder_offset::Column::TargetType.eq(TargetType::ReminderTask))
|
|
.filter(reminder_offset::Column::TargetId.eq(id))
|
|
.exec(&app_data.db)
|
|
.await?;
|
|
|
|
// Delete recipients
|
|
reminder_task_recipient::Entity::delete_many()
|
|
.filter(reminder_task_recipient::Column::TaskId.eq(id))
|
|
.exec(&app_data.db)
|
|
.await?;
|
|
|
|
Ok(HttpResponse::Ok().json(OkResponse { ok: true }))
|
|
}
|
|
|
|
fn parse_recurrence_type(s: &str) -> Result<RecurrenceType, ApiError> {
|
|
match s {
|
|
"hourly" => Ok(RecurrenceType::Hourly),
|
|
"daily" => Ok(RecurrenceType::Daily),
|
|
"weekly" => Ok(RecurrenceType::Weekly),
|
|
"monthly" => Ok(RecurrenceType::Monthly),
|
|
"yearly" => Ok(RecurrenceType::Yearly),
|
|
_ => Err(ApiError::BadRequest("Invalid recurrence type".to_string())),
|
|
}
|
|
}
|
|
|
|
pub fn routes() -> Scope {
|
|
web::scope("/api/reminder-tasks")
|
|
.service(list_tasks)
|
|
.service(create_task)
|
|
.service(get_task)
|
|
.service(update_task)
|
|
.service(delete_task)
|
|
}
|