use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: i64,
pub name: String,
pub identifier: String,
pub description: String,
pub emoji: Option<String>,
pub lead_user_id: Option<i64>,
pub sort_order: i64,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ReorderProjects {
pub ids: Vec<i64>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CreateProject {
pub name: String,
pub identifier: String,
#[serde(default)]
pub description: String,
pub emoji: Option<String>,
pub lead_user_id: Option<i64>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateProject {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(
default,
deserialize_with = "crate::db::models::deserialize_nullable",
skip_serializing_if = "Option::is_none"
)]
pub emoji: Option<Option<String>>,
#[serde(
default,
deserialize_with = "crate::db::models::deserialize_nullable",
skip_serializing_if = "Option::is_none"
)]
pub lead_user_id: Option<Option<i64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectGroup {
pub id: i64,
pub user_id: i64,
pub name: String,
pub sort_order: i64,
pub project_ids: Vec<i64>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateProjectGroup {
pub name: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct UpdateProjectGroup {
pub name: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AssignProjectGroup {
pub project_id: i64,
pub group_id: Option<i64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
#[default]
Backlog,
Todo,
Active,
Done,
Cancelled,
}
impl Status {
pub fn as_str(self) -> &'static str {
match self {
Status::Backlog => "backlog",
Status::Todo => "todo",
Status::Active => "active",
Status::Done => "done",
Status::Cancelled => "cancelled",
}
}
pub fn parse_opt(value: Option<&str>) -> Result<Option<Self>, String> {
value.map(str::parse).transpose()
}
pub fn is_closed(self) -> bool {
matches!(self, Status::Done | Status::Cancelled)
}
}
impl std::fmt::Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Status {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"backlog" => Ok(Status::Backlog),
"todo" => Ok(Status::Todo),
"active" => Ok(Status::Active),
"done" => Ok(Status::Done),
"cancelled" => Ok(Status::Cancelled),
other => Err(format!(
"invalid status '{other}'. Use backlog, todo, active, done, or cancelled."
)),
}
}
}
impl rusqlite::types::FromSql for Status {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
value
.as_str()?
.parse()
.map_err(|_| rusqlite::types::FromSqlError::InvalidType)
}
}
impl rusqlite::types::ToSql for Status {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Priority {
Urgent,
High,
Medium,
Low,
#[default]
None,
}
impl Priority {
pub fn as_str(self) -> &'static str {
match self {
Priority::Urgent => "urgent",
Priority::High => "high",
Priority::Medium => "medium",
Priority::Low => "low",
Priority::None => "none",
}
}
pub fn parse_opt(value: Option<&str>) -> Result<Option<Self>, String> {
value.map(str::parse).transpose()
}
}
impl std::fmt::Display for Priority {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Priority {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"urgent" => Ok(Priority::Urgent),
"high" => Ok(Priority::High),
"medium" => Ok(Priority::Medium),
"low" => Ok(Priority::Low),
"none" => Ok(Priority::None),
other => Err(format!(
"invalid priority '{other}'. Use urgent, high, medium, low, or none."
)),
}
}
}
impl rusqlite::types::FromSql for Priority {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
value
.as_str()?
.parse()
.map_err(|_| rusqlite::types::FromSqlError::InvalidType)
}
}
impl rusqlite::types::ToSql for Priority {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
pub id: i64,
pub project_id: i64,
pub sequence: i64,
pub identifier: String,
pub title: String,
pub description: String,
pub status: Status,
pub priority: Priority,
pub module_id: Option<i64>,
pub sort_order: f64,
pub start_date: Option<String>,
pub target_date: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub seq: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocks: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relates_to: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub duplicates: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub duplicated_by: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectRelation {
pub source_id: i64,
pub source_identifier: String,
pub target_id: i64,
pub target_identifier: String,
pub relation_type: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CreateIssue {
pub project_id: i64,
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub status: Status,
#[serde(default)]
pub priority: Priority,
pub module_id: Option<i64>,
pub start_date: Option<String>,
pub target_date: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
#[serde(default)]
pub source: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateIssue {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<Status>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(
default,
deserialize_with = "crate::db::models::deserialize_nullable",
skip_serializing_if = "Option::is_none"
)]
pub module_id: Option<Option<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_seq: Option<i64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ListIssuesQuery {
pub project_id: Option<i64>,
pub status: Option<Status>,
pub priority: Option<Priority>,
pub module_id: Option<i64>,
pub label: Option<String>,
pub workable: Option<bool>,
pub blocked: Option<bool>,
pub created_since: Option<String>,
pub created_until: Option<String>,
pub updated_since: Option<String>,
pub updated_until: Option<String>,
pub order_by: Option<String>,
pub order: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct IssueStatusCounts {
pub backlog: i64,
pub todo: i64,
pub active: i64,
pub done: i64,
pub cancelled: i64,
pub total: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
pub id: i64,
pub project_id: i64,
pub name: String,
pub description: String,
pub status: String,
pub emoji: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateModule {
pub project_id: i64,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_module_status")]
pub status: String,
pub emoji: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateModule {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(
default,
deserialize_with = "crate::db::models::deserialize_nullable",
skip_serializing_if = "Option::is_none"
)]
pub emoji: Option<Option<String>>,
}
fn default_module_status() -> String {
"active".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label {
pub id: i64,
pub project_id: i64,
pub name: String,
pub color: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateLabel {
pub project_id: i64,
pub name: String,
#[serde(default = "default_label_color")]
pub color: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateLabel {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
fn default_label_color() -> String {
"#6B7280".to_string()
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdateFolder {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Page {
pub id: i64,
pub project_id: Option<i64>,
pub sequence: Option<i64>,
pub identifier: String,
pub folder_id: Option<i64>,
pub title: String,
pub content: String,
pub sort_order: f64,
pub status: String,
#[serde(default)]
pub pinned: bool,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub seq: i64,
#[serde(default)]
pub labels: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreatePage {
pub project_id: Option<i64>,
pub folder_id: Option<i64>,
pub title: String,
#[serde(default)]
pub content: String,
#[serde(default = "default_page_status")]
pub status: String,
#[serde(default)]
pub labels: Vec<String>,
}
impl Default for CreatePage {
fn default() -> Self {
Self {
project_id: None,
folder_id: None,
title: String::new(),
content: String::new(),
status: default_page_status(),
labels: Vec::new(),
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct UpdatePage {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(
default,
deserialize_with = "crate::db::models::deserialize_nullable",
skip_serializing_if = "Option::is_none"
)]
pub folder_id: Option<Option<i64>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort_order: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pinned: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_seq: Option<i64>,
}
fn default_page_status() -> String {
"draft".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Folder {
pub id: i64,
pub project_id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub sort_order: f64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateFolder {
pub project_id: i64,
pub parent_id: Option<i64>,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Viewer,
Maintainer,
Lead,
}
impl Role {
pub fn as_str(self) -> &'static str {
match self {
Role::Viewer => "viewer",
Role::Maintainer => "maintainer",
Role::Lead => "lead",
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"viewer" => Ok(Role::Viewer),
"maintainer" => Ok(Role::Maintainer),
"lead" => Ok(Role::Lead),
other => Err(format!("invalid role: {other:?}")),
}
}
}
impl rusqlite::types::FromSql for Role {
fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
value
.as_str()?
.parse()
.map_err(|_| rusqlite::types::FromSqlError::InvalidType)
}
}
impl rusqlite::types::ToSql for Role {
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
Ok(self.as_str().into())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMember {
pub project_id: i64,
pub user_id: i64,
pub role: Role,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberWithUser {
pub project_id: i64,
pub user_id: i64,
pub role: Role,
pub created_at: String,
pub username: String,
pub display_name: String,
}
#[derive(Debug, Deserialize)]
pub struct AddMember {
pub user_id: i64,
pub role: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ChangeMemberRole {
pub role: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: i64,
pub username: String,
pub email: String,
#[serde(skip_serializing)]
pub password_hash: String,
pub display_name: String,
pub is_admin: bool,
pub is_bot: bool,
pub is_active: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateUser {
pub username: String,
pub email: String,
pub password: String,
pub display_name: Option<String>,
#[serde(default)]
pub is_admin: bool,
#[serde(default)]
pub is_bot: bool,
}
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
pub identity: String,
pub password: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthUser {
pub id: i64,
pub username: String,
pub display_name: String,
pub is_admin: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommentActor {
pub user_id: i64,
pub is_admin: bool,
}
impl From<&AuthUser> for CommentActor {
fn from(user: &AuthUser) -> Self {
Self {
user_id: user.id,
is_admin: user.is_admin,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub token: String,
pub user_id: i64,
pub expires_at: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bot {
pub id: i64,
pub username: String,
pub display_name: String,
pub owner_id: Option<i64>,
pub created_at: String,
pub connected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserApiKey {
pub id: i64,
pub name: String,
pub created_at: String,
pub expires_at: Option<String>,
pub revoked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: i64,
pub issue_id: Option<i64>,
pub page_id: Option<i64>,
pub user_id: i64,
pub author: String,
pub author_display_name: String,
pub content: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub seq: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateComment {
pub content: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateComment {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MentionCandidate {
pub user_id: i64,
pub username: String,
pub display_name: String,
}
#[derive(Debug, Default, Deserialize)]
pub struct SearchQuery {
pub query: String,
pub project_id: Option<i64>,
pub result_type: Option<String>,
pub sort: Option<String>,
pub mode: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchResult {
pub result_type: String,
pub id: i64,
pub identifier: Option<String>,
pub title: String,
pub snippet: String,
pub project_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_page_id: Option<i64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Activity {
pub id: i64,
pub ts: String,
pub actor_user_id: Option<i64>,
pub actor_username: Option<String>,
pub actor_display_name: Option<String>,
pub actor_is_bot: bool,
pub transport: String,
pub entity_type: String,
pub entity_id: i64,
pub entity_label: Option<String>,
pub project_id: Option<i64>,
pub issue_id: Option<i64>,
pub page_id: Option<i64>,
pub action: String,
pub field: Option<String>,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct ActivityFeed {
pub items: Vec<Activity>,
pub has_more: bool,
}
#[derive(Debug, serde::Serialize)]
pub struct ActorStat {
pub actor_user_id: Option<i64>,
pub username: Option<String>,
pub display_name: Option<String>,
pub is_bot: bool,
pub actions: i64,
pub last_ts: String,
pub top_transport: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct WeekPoint {
pub week_start: String,
pub count: i64,
}
#[derive(Debug, Default, Serialize)]
pub struct PriorityCounts {
pub urgent: i64,
pub high: i64,
pub medium: i64,
pub low: i64,
pub none: i64,
pub total: i64,
}
#[derive(Debug, Serialize)]
pub struct ModuleCount {
pub module_id: Option<i64>,
pub name: String,
pub count: i64,
}
#[derive(Debug, Serialize)]
pub struct InsightsPayload {
pub weeks: i64,
pub created_per_week: Vec<WeekPoint>,
pub closed_per_week: Vec<WeekPoint>,
pub status_counts: IssueStatusCounts,
pub priority_counts: PriorityCounts,
pub module_counts: Vec<ModuleCount>,
pub top_actors: Vec<ActorStat>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeKind {
Issue,
Page,
Comment,
}
#[derive(Debug, Clone, Serialize)]
pub struct IssueChange {
pub kind: ChangeKind,
pub seq: i64,
pub deleted: bool,
pub id: i64,
pub identifier: String,
pub title: String,
pub status: Status,
pub priority: Priority,
pub module_id: Option<i64>,
pub sort_order: f64,
pub start_date: Option<String>,
pub target_date: Option<String>,
pub created_at: String,
pub updated_at: String,
pub preview: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PageChange {
pub kind: ChangeKind,
pub seq: i64,
pub deleted: bool,
pub id: i64,
pub identifier: String,
pub title: String,
pub status: String,
pub folder_id: Option<i64>,
pub pinned: bool,
pub created_at: String,
pub updated_at: String,
pub preview: String,
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CommentChange {
pub kind: ChangeKind,
pub seq: i64,
pub deleted: bool,
pub id: i64,
pub issue_id: Option<i64>,
pub page_id: Option<i64>,
pub user_id: i64,
pub username: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Tombstone {
pub kind: ChangeKind,
pub seq: i64,
pub deleted: bool,
pub id: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub enum Change {
Issue(IssueChange),
Page(PageChange),
Comment(CommentChange),
Tombstone(Tombstone),
}
impl Change {
pub fn seq(&self) -> i64 {
match self {
Change::Issue(row) => row.seq,
Change::Page(row) => row.seq,
Change::Comment(row) => row.seq,
Change::Tombstone(row) => row.seq,
}
}
}
#[derive(Debug, Serialize)]
pub struct ChangesPage {
pub changes: Vec<Change>,
pub cursor: i64,
pub has_more: bool,
}
#[derive(Debug, Serialize)]
pub struct IndexSnapshot {
pub cursor: i64,
pub issues: Vec<IssueChange>,
pub pages: Vec<PageChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub id: i64,
pub project_id: i64,
pub sequence: i64,
pub identifier: String,
pub issue_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_identifier: Option<String>,
pub title: String,
pub status: String,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub steps: Vec<PlanStepNode>,
#[serde(default)]
pub step_count: i64,
#[serde(default)]
pub done_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanStepNode {
pub id: i64,
pub plan_id: i64,
pub parent_step_id: Option<i64>,
pub position: i64,
pub title: String,
pub description: String,
pub issue_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issue_identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub issue_status: Option<String>,
pub done: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reopened_via_issue_at: Option<String>,
pub created_at: String,
pub edited_at: Option<String>,
#[serde(default)]
pub children: Vec<PlanStepNode>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePlan {
pub project_id: i64,
pub title: String,
pub issue_id: Option<i64>,
#[serde(default)]
pub steps: Vec<CreatePlanStep>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePlanStep {
pub title: String,
#[serde(default)]
pub description: String,
pub issue_id: Option<i64>,
#[serde(default)]
pub done: bool,
#[serde(default)]
pub steps: Vec<CreatePlanStep>,
}
#[derive(Debug, Default, Deserialize)]
pub struct UpdatePlan {
pub title: Option<String>,
pub status: Option<String>,
#[serde(default, deserialize_with = "crate::db::models::deserialize_nullable")]
pub issue_id: Option<Option<i64>>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ListPlansQuery {
pub project_id: Option<i64>,
pub status: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub order_by: Option<String>,
pub before_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedView {
pub id: i64,
pub project_id: i64,
pub user_id: i64,
pub name: String,
pub config: String,
pub is_default: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CreateSavedView {
pub name: String,
pub config: String,
#[serde(default)]
pub is_default: bool,
}
#[derive(Debug, Default, Deserialize)]
pub struct UpdateSavedView {
pub name: Option<String>,
pub config: Option<String>,
pub is_default: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attachment {
pub id: i64,
#[serde(skip_serializing)]
pub sha256: String,
pub filename: String,
pub mime: String,
pub size_bytes: i64,
pub uploader_id: Option<i64>,
pub created_at: String,
#[serde(default)]
pub width: Option<i64>,
#[serde(default)]
pub height: Option<i64>,
#[serde(default)]
pub alt_text: Option<String>,
#[serde(default, skip_deserializing)]
pub has_thumbnail: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AttachmentEntity {
Issue,
Page,
Comment,
}
impl AttachmentEntity {
pub fn as_str(self) -> &'static str {
match self {
AttachmentEntity::Issue => "issue",
AttachmentEntity::Page => "page",
AttachmentEntity::Comment => "comment",
}
}
}
impl std::str::FromStr for AttachmentEntity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"issue" => Ok(AttachmentEntity::Issue),
"page" => Ok(AttachmentEntity::Page),
"comment" => Ok(AttachmentEntity::Comment),
other => Err(format!("invalid attachment entity: {other:?}")),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LinkedEntity {
pub entity_type: String,
pub entity_id: i64,
pub identifier: Option<String>,
pub title: String,
pub page_id: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectAttachment {
pub id: i64,
pub filename: String,
pub mime: String,
pub mime_class: String,
pub size_bytes: i64,
pub uploader_id: Option<i64>,
pub uploader: Option<String>,
pub uploader_display_name: Option<String>,
pub created_at: String,
pub entities: Vec<LinkedEntity>,
}
#[derive(Debug, Serialize)]
pub struct ProjectAttachmentPage {
pub items: Vec<ProjectAttachment>,
pub has_more: bool,
pub total_count: i64,
pub total_bytes: i64,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProjectAttachmentQuery {
pub mime_class: Option<String>,
pub uploader: Option<String>,
pub entity_type: Option<String>,
pub sort: Option<String>,
pub order: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PendingOrphan {
pub id: i64,
pub filename: String,
pub mime: String,
pub size_bytes: i64,
pub uploader_id: Option<i64>,
pub uploader: Option<String>,
pub uploaded_at: String,
pub age_seconds: i64,
pub seconds_until_sweep: i64,
}
#[derive(Debug, Serialize)]
pub struct PendingOrphanList {
pub items: Vec<PendingOrphan>,
pub grace_seconds: i64,
pub total_bytes: i64,
}
pub fn deserialize_nullable<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
T: serde::Deserialize<'de>,
D: serde::Deserializer<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::types::{FromSql, ToSql, ValueRef};
const STATUSES: [Status; 5] = [
Status::Backlog,
Status::Todo,
Status::Active,
Status::Done,
Status::Cancelled,
];
const PRIORITIES: [Priority; 5] = [
Priority::Urgent,
Priority::High,
Priority::Medium,
Priority::Low,
Priority::None,
];
#[test]
fn status_serde_round_trips_as_the_lowercase_wire_string() {
for status in STATUSES {
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, format!("\"{}\"", status.as_str()));
assert_eq!(serde_json::from_str::<Status>(&json).unwrap(), status);
assert_eq!(status.to_string(), status.as_str());
assert_eq!(status.as_str().parse::<Status>().unwrap(), status);
}
}
#[test]
fn priority_serde_round_trips_as_the_lowercase_wire_string() {
for priority in PRIORITIES {
let json = serde_json::to_string(&priority).unwrap();
assert_eq!(json, format!("\"{}\"", priority.as_str()));
assert_eq!(serde_json::from_str::<Priority>(&json).unwrap(), priority);
assert_eq!(priority.to_string(), priority.as_str());
assert_eq!(priority.as_str().parse::<Priority>().unwrap(), priority);
}
}
#[test]
fn status_and_priority_round_trip_through_sqlite() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE t (
status TEXT NOT NULL
CHECK(status IN ('backlog','todo','active','done','cancelled')),
priority TEXT NOT NULL
CHECK(priority IN ('urgent','high','medium','low','none'))
)",
)
.unwrap();
for status in STATUSES {
for priority in PRIORITIES {
conn.execute(
"INSERT INTO t (status, priority) VALUES (?1, ?2)",
rusqlite::params![status, priority],
)
.unwrap();
let (got_status, got_priority) = conn
.query_row("SELECT status, priority FROM t", [], |row| {
Ok((row.get::<_, Status>(0)?, row.get::<_, Priority>(1)?))
})
.unwrap();
assert_eq!(got_status, status);
assert_eq!(got_priority, priority);
conn.execute("DELETE FROM t", []).unwrap();
}
}
}
#[test]
fn unknown_column_values_fail_to_convert_rather_than_defaulting() {
assert!(Status::column_result(ValueRef::Text(b"shipped")).is_err());
assert!(Priority::column_result(ValueRef::Text(b"critical")).is_err());
assert_eq!(
Status::Done.to_sql().unwrap(),
rusqlite::types::ToSqlOutput::from("done")
);
assert_eq!(
Priority::Low.to_sql().unwrap(),
rusqlite::types::ToSqlOutput::from("low")
);
}
#[test]
fn parsing_an_unknown_value_names_the_valid_ones() {
assert_eq!(
"shipped".parse::<Status>().unwrap_err(),
"invalid status 'shipped'. Use backlog, todo, active, done, or cancelled."
);
assert_eq!(
"critical".parse::<Priority>().unwrap_err(),
"invalid priority 'critical'. Use urgent, high, medium, low, or none."
);
assert!("Done".parse::<Status>().is_err());
}
#[test]
fn parse_opt_passes_absent_values_through() {
assert_eq!(Status::parse_opt(None).unwrap(), None);
assert_eq!(
Status::parse_opt(Some("active")).unwrap(),
Some(Status::Active)
);
assert!(Status::parse_opt(Some("nope")).is_err());
assert_eq!(Priority::parse_opt(None).unwrap(), None);
assert_eq!(
Priority::parse_opt(Some("low")).unwrap(),
Some(Priority::Low)
);
assert!(Priority::parse_opt(Some("nope")).is_err());
}
#[test]
fn omitted_status_and_priority_default_to_backlog_and_none() {
assert_eq!(Status::default(), Status::Backlog);
assert_eq!(Priority::default(), Priority::None);
let created: CreateIssue =
serde_json::from_str(r#"{"project_id": 1, "title": "New"}"#).unwrap();
assert_eq!(created.status, Status::Backlog);
assert_eq!(created.priority, Priority::None);
let defaulted = CreateIssue::default();
assert_eq!(defaulted.status, Status::Backlog);
assert_eq!(defaulted.priority, Priority::None);
}
#[test]
fn only_done_and_cancelled_count_as_closed() {
assert!(Status::Done.is_closed());
assert!(Status::Cancelled.is_closed());
assert!(!Status::Backlog.is_closed());
assert!(!Status::Todo.is_closed());
assert!(!Status::Active.is_closed());
}
}