Skip to main content

wekan_cli/client/
cards.rs

1use crate::client::metadata::*;
2use reqwest::{StatusCode, header::ACCEPT};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Deserializer, Serialize};
5use serde_json::Number;
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9mod archival;
10pub mod bot_runs;
11mod copy;
12mod export;
13pub mod import;
14mod reads;
15pub use copy::{CardCopyPosition, CopyCardRequest};
16pub use reads::SwimlaneCardSummary;
17pub mod bulk;
18mod custom_fields;
19pub mod membership;
20pub use archival::{ArchiveCardResult, RestoreCardResult};
21pub(crate) use custom_fields::validate_custom_field_query_value;
22pub use custom_fields::{SetCardCustomFieldRequest, SetCardCustomFieldResponse};
23mod inputs;
24pub use inputs::{
25    CardCustomFieldInput, CardLocationInput, CardPokerInput, CardStickerInput, CardVoteInput,
26};
27
28pub const CARD_COLORS: &[&str] = &[
29    "white",
30    "green",
31    "yellow",
32    "orange",
33    "red",
34    "purple",
35    "blue",
36    "sky",
37    "lime",
38    "pink",
39    "black",
40    "silver",
41    "peachpuff",
42    "crimson",
43    "plum",
44    "darkgreen",
45    "slateblue",
46    "magenta",
47    "gold",
48    "navy",
49    "gray",
50    "saddlebrown",
51    "paleturquoise",
52    "mistyrose",
53    "indigo",
54];
55
56#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
57#[serde(untagged)]
58pub enum CardDateInput {
59    Text(String),
60    EpochMilliseconds(Number),
61}
62
63impl From<String> for CardDateInput {
64    fn from(value: String) -> Self {
65        Self::Text(value)
66    }
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70#[serde(
71    deny_unknown_fields,
72    rename_all(deserialize = "camelCase", serialize = "snake_case")
73)]
74pub struct CardSummary {
75    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
76    pub card_id: String,
77    pub title: Option<String>,
78    pub description: Option<String>,
79    pub swimlane_id: Option<String>,
80    pub received_at: Option<String>,
81    pub start_at: Option<String>,
82    pub due_at: Option<String>,
83    pub end_at: Option<String>,
84    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
85    pub assignees: Vec<String>,
86    pub sort: Option<Number>,
87}
88
89#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(untagged)]
91pub enum CardCustomFieldValue {
92    String(String),
93    Number(Number),
94    Boolean(bool),
95    Strings(Vec<Option<String>>),
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
99#[serde(
100    deny_unknown_fields,
101    rename_all(deserialize = "camelCase", serialize = "snake_case")
102)]
103pub struct CardCustomField {
104    #[serde(rename(deserialize = "_id", serialize = "custom_field_id"))]
105    pub custom_field_id: Option<String>,
106    pub value: Option<CardCustomFieldValue>,
107}
108
109#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum CardStickerHighlight {
112    Underline,
113    Round,
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(deny_unknown_fields, rename_all = "snake_case")]
118pub struct CardSticker {
119    pub icon: Option<String>,
120    pub name: Option<String>,
121    pub highlight: Option<CardStickerHighlight>,
122    pub position: Option<Number>,
123}
124
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126#[serde(
127    deny_unknown_fields,
128    rename_all(deserialize = "camelCase", serialize = "snake_case")
129)]
130pub struct CardLocation {
131    #[serde(rename(deserialize = "_id", serialize = "location_id"))]
132    pub location_id: String,
133    pub name: Option<String>,
134    pub address: Option<String>,
135    pub latitude: Option<Number>,
136    pub longitude: Option<Number>,
137}
138
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140#[serde(
141    deny_unknown_fields,
142    rename_all(deserialize = "camelCase", serialize = "snake_case")
143)]
144pub struct CardDependency {
145    pub card_id: String,
146    #[serde(rename(deserialize = "type", serialize = "dependency_type"))]
147    pub dependency_type: Option<String>,
148    pub color: Option<String>,
149    pub icon: Option<String>,
150}
151
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
153#[serde(
154    deny_unknown_fields,
155    rename_all(deserialize = "camelCase", serialize = "snake_case")
156)]
157pub struct CardVote {
158    pub question: String,
159    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
160    pub positive: Vec<String>,
161    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
162    pub negative: Vec<String>,
163    pub end: Option<String>,
164    pub public: bool,
165    pub allow_non_board_members: bool,
166}
167
168#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169#[serde(
170    deny_unknown_fields,
171    rename_all(deserialize = "camelCase", serialize = "snake_case")
172)]
173pub struct CardPoker {
174    pub question: Option<bool>,
175    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
176    pub one: Vec<String>,
177    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
178    pub two: Vec<String>,
179    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
180    pub three: Vec<String>,
181    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
182    pub five: Vec<String>,
183    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
184    pub eight: Vec<String>,
185    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
186    pub thirteen: Vec<String>,
187    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
188    pub twenty: Vec<String>,
189    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
190    pub forty: Vec<String>,
191    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
192    pub one_hundred: Vec<String>,
193    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
194    pub unsure: Vec<String>,
195    pub end: Option<String>,
196    pub allow_non_board_members: Option<bool>,
197    pub estimation: Option<Number>,
198}
199
200#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
201#[serde(
202    deny_unknown_fields,
203    rename_all(deserialize = "camelCase", serialize = "snake_case")
204)]
205pub struct CardDocument {
206    pub scrum: Option<Box<ScrumCard>>,
207    pub drawings: Option<Vec<CardDrawing>>,
208    pub custom_field_links: Option<Vec<CardCustomFieldLink>>,
209    pub sync_last_source: Option<Box<CardSyncLastSource>>,
210    pub email: Option<Box<CardEmail>>,
211
212    pub scrum_revision: Option<serde_json::Number>,
213    pub parent_ids: Option<Vec<String>>,
214    pub map_x: Option<serde_json::Number>,
215    pub map_y: Option<serde_json::Number>,
216    pub column_set_values: Option<std::collections::BTreeMap<String, String>>,
217    pub card_kind: Option<String>,
218    pub last_recurrence_at: Option<String>,
219    pub sync_source_key: Option<String>,
220    pub sync_label_ids: Option<Vec<String>>,
221    pub sync_issue_state: Option<String>,
222    pub sync_closed_by_we_kan: Option<bool>,
223    pub sync_external_id: Option<String>,
224    pub sync_source_type: Option<String>,
225    pub attached_card_ids: Option<Vec<String>>,
226    pub flow_start_at: Option<String>,
227    pub flow_user_id: Option<String>,
228    pub pomodoro_start_at: Option<String>,
229    pub pomodoro_phase: Option<String>,
230    pub pomodoro_user_id: Option<String>,
231    pub show_list_on_card: Option<bool>,
232    pub checklists: Option<Vec<crate::client::checklists::ChecklistDocument>>,
233    pub list_entered_at: Option<String>,
234    pub recurrence_interval: Option<String>,
235    pub last_move_reason: Option<String>,
236    pub flow_interruptions: Option<Number>,
237    pub pomodoro_count: Option<Number>,
238    pub pomodoro_work_minutes: Option<Number>,
239    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
240    pub card_id: String,
241    pub title: Option<String>,
242    #[serde(default, deserialize_with = "deserialize_watchers")]
243    pub watchers: Option<Vec<String>>,
244    pub archived: bool,
245    pub archived_at: Option<String>,
246    pub deleted_at: Option<String>,
247    pub deleted_by: Option<String>,
248    pub delete_batch_id: Option<String>,
249    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
250    pub parent_id: Option<String>,
251    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
252    pub list_id: Option<String>,
253    pub swimlane_id: String,
254    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
255    pub board_id: Option<String>,
256    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
257    pub cover_id: Option<String>,
258    pub color: Option<String>,
259    pub created_at: String,
260    pub modified_at: String,
261    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
262    pub custom_fields: Vec<CardCustomField>,
263    pub date_last_activity: String,
264    pub description: Option<String>,
265    pub requested_by: Option<String>,
266    pub assigned_by: Option<String>,
267    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
268    pub label_ids: Vec<Option<String>>,
269    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
270    pub members: Vec<String>,
271    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
272    pub assignees: Vec<String>,
273    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
274    pub requesters: Vec<String>,
275    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
276    pub assigners: Vec<String>,
277    pub received_at: Option<String>,
278    pub start_at: Option<String>,
279    pub due_at: Option<String>,
280    pub end_at: Option<String>,
281    pub due_complete: Option<bool>,
282    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
283    pub stickers: Vec<CardSticker>,
284    pub location_name: Option<String>,
285    pub location_address: Option<String>,
286    pub location_latitude: Option<Number>,
287    pub location_longitude: Option<Number>,
288    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
289    pub locations: Vec<CardLocation>,
290    pub spent_time: Option<Number>,
291    pub is_overtime: Option<bool>,
292    pub user_id: String,
293    pub sort: Option<Number>,
294    pub subtask_sort: Option<Number>,
295    #[serde(rename(deserialize = "type", serialize = "card_type"))]
296    pub card_type: String,
297    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
298    pub linked_id: Option<String>,
299    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
300    pub card_dependencies: Vec<CardDependency>,
301    pub vote: Option<CardVote>,
302    pub poker: Option<CardPoker>,
303    #[serde(
304        rename(deserialize = "targetId_gantt", serialize = "target_id_gantt"),
305        default,
306        deserialize_with = "deserialize_nullable_vec"
307    )]
308    pub target_id_gantt: Vec<String>,
309    #[serde(
310        rename(deserialize = "linkType_gantt", serialize = "link_type_gantt"),
311        default,
312        deserialize_with = "deserialize_nullable_vec"
313    )]
314    pub link_type_gantt: Vec<Number>,
315    #[serde(
316        rename(deserialize = "linkId_gantt", serialize = "link_id_gantt"),
317        default,
318        deserialize_with = "deserialize_nullable_vec"
319    )]
320    pub link_id_gantt: Vec<String>,
321    pub card_number: Option<Number>,
322    pub show_activities: bool,
323    pub show_list_on_minicard: Option<bool>,
324    pub show_checklist_at_minicard: Option<bool>,
325    pub hide_finished_checklist_if_items_are_hidden: Option<bool>,
326}
327
328#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
329#[serde(rename_all = "camelCase")]
330pub struct CreateCardRequest {
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub title: Option<Option<String>>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub linked_id: Option<String>,
335    pub swimlane_id: String,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub description: Option<Option<String>>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub members: Option<Vec<String>>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub assignees: Option<Vec<String>>,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub received_at: Option<CardDateInput>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub start_at: Option<CardDateInput>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub due_at: Option<CardDateInput>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub end_at: Option<CardDateInput>,
350}
351
352#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
353#[serde(rename_all = "camelCase")]
354pub struct UpdateCardRequest {
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub move_reason: Option<String>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub title: Option<String>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub sort: Option<Number>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub parent_id: Option<String>,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub description: Option<String>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub color: Option<String>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub label_ids: Option<Vec<Option<String>>>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub requested_by: Option<String>,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub assigned_by: Option<String>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub received_at: Option<CardDateInput>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub start_at: Option<CardDateInput>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub due_at: Option<CardDateInput>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub end_at: Option<CardDateInput>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub spent_time: Option<Number>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub is_over_time: Option<bool>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub members: Option<Vec<String>>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub assignees: Option<Vec<String>>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub due_complete: Option<bool>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub swimlane_id: Option<String>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub list_id: Option<String>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub new_board_id: Option<String>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub new_swimlane_id: Option<String>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub new_list_id: Option<String>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub archive: Option<bool>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub vote: Option<CardVoteInput>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub poker: Option<CardPokerInput>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub custom_fields: Option<Vec<CardCustomFieldInput>>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub stickers: Option<Vec<CardStickerInput>>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub locations: Option<Vec<CardLocationInput>>,
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct CreateCardResult {
417    pub card_id: String,
418}
419
420#[derive(Clone, Debug, Eq, PartialEq)]
421pub struct UpdateCardResult {
422    pub card_id: String,
423}
424
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub struct DeleteCardResult {
427    pub card_id: String,
428}
429
430#[derive(Deserialize)]
431#[serde(deny_unknown_fields)]
432struct IdResponse {
433    #[serde(rename = "_id")]
434    id: String,
435}
436
437impl WekanClient {
438    pub async fn board_list_cards(
439        &self,
440        board_id: &str,
441        list_id: &str,
442        token: &SecretString,
443    ) -> Result<Vec<CardSummary>, ClientError> {
444        let body = self
445            .execute_success_request(
446                self.http
447                    .get(self.card_endpoint(board_id, list_id, None)?)
448                    .header(ACCEPT, "application/json")
449                    .bearer_auth(token.expose_secret()),
450            )
451            .await?;
452        let cards = decode_success(&body, "board list card collection", true)?;
453        validate_card_summaries(cards)
454    }
455
456    pub async fn card(
457        &self,
458        board_id: &str,
459        list_id: &str,
460        card_id: &str,
461        token: &SecretString,
462    ) -> Result<CardDocument, ClientError> {
463        let body = self
464            .execute_success_request(
465                self.http
466                    .get(self.card_endpoint(board_id, list_id, Some(card_id))?)
467                    .header(ACCEPT, "application/json")
468                    .bearer_auth(token.expose_secret()),
469            )
470            .await?;
471        if body.is_empty() {
472            return Err(ClientError::EmbeddedServer {
473                http_status: StatusCode::OK,
474                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
475                server_error: Some("card-not-found".to_owned()),
476                server_reason: Some("Card not found".to_owned()),
477            });
478        }
479        let card = decode_success(&body, "card", true)?;
480        validate_card_document(card)
481    }
482
483    pub async fn create_card(
484        &self,
485        board_id: &str,
486        list_id: &str,
487        request: &CreateCardRequest,
488        token: &SecretString,
489    ) -> Result<CreateCardResult, ClientError> {
490        let body = self
491            .execute_success_request(
492                self.http
493                    .post(self.card_endpoint(board_id, list_id, None)?)
494                    .header(ACCEPT, "application/json")
495                    .bearer_auth(token.expose_secret())
496                    .json(request),
497            )
498            .await?;
499        let response: IdResponse = decode_success(&body, "card creation", true)?;
500        require_non_empty(&response.id, "card creation", "card id")?;
501        Ok(CreateCardResult {
502            card_id: response.id,
503        })
504    }
505
506    pub async fn update_card(
507        &self,
508        board_id: &str,
509        list_id: &str,
510        card_id: &str,
511        request: &UpdateCardRequest,
512        token: &SecretString,
513    ) -> Result<UpdateCardResult, ClientError> {
514        let body = self
515            .execute_success_request(
516                self.http
517                    .put(self.card_endpoint(board_id, list_id, Some(card_id))?)
518                    .header(ACCEPT, "application/json")
519                    .bearer_auth(token.expose_secret())
520                    .json(request),
521            )
522            .await?;
523        let response: IdResponse = decode_success(&body, "card update", true)?;
524        require_non_empty(&response.id, "card update", "card id")?;
525        Ok(UpdateCardResult {
526            card_id: response.id,
527        })
528    }
529
530    pub async fn delete_card(
531        &self,
532        board_id: &str,
533        list_id: &str,
534        card_id: &str,
535        token: &SecretString,
536    ) -> Result<DeleteCardResult, ClientError> {
537        let body = self
538            .execute_success_request(
539                self.http
540                    .delete(self.card_endpoint(board_id, list_id, Some(card_id))?)
541                    .header(ACCEPT, "application/json")
542                    .bearer_auth(token.expose_secret()),
543            )
544            .await?;
545        let response: IdResponse = decode_success(&body, "card deletion", true)?;
546        require_non_empty(&response.id, "card deletion", "card id")?;
547        Ok(DeleteCardResult {
548            card_id: response.id,
549        })
550    }
551
552    fn card_endpoint(
553        &self,
554        board_id: &str,
555        list_id: &str,
556        card_id: Option<&str>,
557    ) -> Result<reqwest::Url, ClientError> {
558        let mut endpoint =
559            self.server()
560                .join("api/boards")
561                .map_err(|error| ClientError::Protocol {
562                    diagnostic: None,
563                    message: format!("could not build the card endpoint: {error}"),
564                    success_status_received: false,
565                })?;
566        let mut segments = endpoint
567            .path_segments_mut()
568            .map_err(|()| ClientError::Protocol {
569                diagnostic: None,
570                message: "could not add identifiers to the card endpoint".to_owned(),
571                success_status_received: false,
572            })?;
573        segments.push(board_id);
574        segments.push("lists");
575        segments.push(list_id);
576        segments.push("cards");
577        if let Some(card_id) = card_id {
578            segments.push(card_id);
579        }
580        drop(segments);
581        Ok(endpoint)
582    }
583}
584
585pub fn is_valid_card_color(value: &str) -> bool {
586    CARD_COLORS.contains(&value)
587        || value.len() == 7
588            && value.starts_with('#')
589            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
590}
591
592fn validate_card_summaries(cards: Vec<CardSummary>) -> Result<Vec<CardSummary>, ClientError> {
593    for card in &cards {
594        require_non_empty(&card.card_id, "board list card collection", "card id")?;
595        validate_optional_id(
596            card.swimlane_id.as_deref(),
597            "board list card collection",
598            "swimlane id",
599        )?;
600        validate_ids(&card.assignees, "board list card collection", "assignee id")?;
601        for (field, value) in [
602            ("receivedAt", card.received_at.as_deref()),
603            ("startAt", card.start_at.as_deref()),
604            ("dueAt", card.due_at.as_deref()),
605            ("endAt", card.end_at.as_deref()),
606        ] {
607            validate_optional_date_time(value, field, "board list card collection")?;
608        }
609    }
610    Ok(cards)
611}
612
613fn validate_card_document(card: CardDocument) -> Result<CardDocument, ClientError> {
614    require_non_empty(&card.card_id, "card", "card id")?;
615    require_non_empty(&card.swimlane_id, "card", "swimlane id")?;
616    require_non_empty(&card.user_id, "card", "user id")?;
617    if !matches!(
618        card.card_type.as_str(),
619        "cardType-card" | "cardType-linkedCard" | "cardType-linkedBoard" | "template-card"
620    ) {
621        return Err(protocol_error(format!(
622            "the card response contained an invalid type `{}`",
623            card.card_type
624        )));
625    }
626    validate_date_time(&card.created_at, "createdAt", "card")?;
627    validate_date_time(&card.modified_at, "modifiedAt", "card")?;
628    validate_date_time(&card.date_last_activity, "dateLastActivity", "card")?;
629    for (field, value) in [
630        ("listEnteredAt", card.list_entered_at.as_deref()),
631        ("lastRecurrenceAt", card.last_recurrence_at.as_deref()),
632        ("flowStartAt", card.flow_start_at.as_deref()),
633        ("pomodoroStartAt", card.pomodoro_start_at.as_deref()),
634        ("archivedAt", card.archived_at.as_deref()),
635        ("deletedAt", card.deleted_at.as_deref()),
636        ("receivedAt", card.received_at.as_deref()),
637        ("startAt", card.start_at.as_deref()),
638        ("dueAt", card.due_at.as_deref()),
639        ("endAt", card.end_at.as_deref()),
640        (
641            "vote.end",
642            card.vote.as_ref().and_then(|vote| vote.end.as_deref()),
643        ),
644        (
645            "poker.end",
646            card.poker.as_ref().and_then(|poker| poker.end.as_deref()),
647        ),
648    ] {
649        validate_optional_date_time(value, field, "card")?;
650    }
651    for (field, value) in [
652        ("parent id", card.parent_id.as_deref()),
653        ("list id", card.list_id.as_deref()),
654        ("board id", card.board_id.as_deref()),
655        ("cover id", card.cover_id.as_deref()),
656        ("linked id", card.linked_id.as_deref()),
657    ] {
658        validate_optional_id(value, "card", field)?;
659    }
660    if let Some(color) = &card.color
661        && !color.is_empty()
662        && !is_valid_card_color(color)
663    {
664        return Err(protocol_error(format!(
665            "the card response contained an invalid color `{color}`"
666        )));
667    }
668    for location in &card.locations {
669        require_non_empty(&location.location_id, "card", "location id")?;
670    }
671    for dependency in &card.card_dependencies {
672        require_non_empty(&dependency.card_id, "card", "dependency card id")?;
673    }
674    for (values, field) in [
675        (&card.members, "member id"),
676        (&card.assignees, "assignee id"),
677        (&card.requesters, "requester id"),
678        (&card.assigners, "assigner id"),
679        (&card.target_id_gantt, "Gantt target id"),
680        (&card.link_id_gantt, "Gantt link id"),
681    ] {
682        validate_ids(values, "card", field)?;
683    }
684    for label_id in card.label_ids.iter().flatten() {
685        require_non_empty(label_id, "card", "label id")?;
686    }
687    if let Some(vote) = &card.vote {
688        validate_ids(&vote.positive, "card", "positive voter id")?;
689        validate_ids(&vote.negative, "card", "negative voter id")?;
690    }
691    if let Some(poker) = &card.poker {
692        for (values, field) in [
693            (&poker.one, "poker voter id"),
694            (&poker.two, "poker voter id"),
695            (&poker.three, "poker voter id"),
696            (&poker.five, "poker voter id"),
697            (&poker.eight, "poker voter id"),
698            (&poker.thirteen, "poker voter id"),
699            (&poker.twenty, "poker voter id"),
700            (&poker.forty, "poker voter id"),
701            (&poker.one_hundred, "poker voter id"),
702            (&poker.unsure, "poker voter id"),
703        ] {
704            validate_ids(values, "card", field)?;
705        }
706    }
707    Ok(card)
708}
709
710fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
711where
712    D: Deserializer<'de>,
713    T: Deserialize<'de>,
714{
715    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
716}
717
718fn deserialize_optional_non_empty_string<'de, D>(
719    deserializer: D,
720) -> Result<Option<String>, D::Error>
721where
722    D: Deserializer<'de>,
723{
724    Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
725}
726
727fn validate_ids(values: &[String], operation: &str, field: &str) -> Result<(), ClientError> {
728    for value in values {
729        require_non_empty(value, operation, field)?;
730    }
731    Ok(())
732}
733
734fn validate_optional_id(
735    value: Option<&str>,
736    operation: &str,
737    field: &str,
738) -> Result<(), ClientError> {
739    if let Some(value) = value {
740        require_non_empty(value, operation, field)?;
741    }
742    Ok(())
743}
744
745fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
746    if value.trim().is_empty() {
747        return Err(protocol_error(format!(
748            "the {operation} response contained an empty {field}"
749        )));
750    }
751    Ok(())
752}
753
754fn validate_optional_date_time(
755    value: Option<&str>,
756    field: &str,
757    operation: &str,
758) -> Result<(), ClientError> {
759    if let Some(value) = value {
760        validate_date_time(value, field, operation)?;
761    }
762    Ok(())
763}
764
765fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
766    super::datetime::validate_response_date(value).map_err(|_| {
767        protocol_error(format!(
768            "the {operation} response contained an invalid {field} date-time"
769        ))
770    })
771}
772
773fn protocol_error(message: String) -> ClientError {
774    ClientError::Protocol {
775        diagnostic: None,
776        message,
777        success_status_received: true,
778    }
779}
780
781fn deserialize_watchers<'de, D: serde::Deserializer<'de>>(
782    d: D,
783) -> Result<Option<Vec<String>>, D::Error> {
784    Vec::<String>::deserialize(d).map(Some)
785}
786
787#[cfg(test)]
788mod tests {
789    use serde_json::json;
790
791    use super::{CardDocument, CardSummary, is_valid_card_color};
792
793    #[test]
794    fn color_validation_matches_wekan_v12_57() {
795        for value in ["white", "silver", "indigo", "#12aBcF"] {
796            assert!(is_valid_card_color(value));
797        }
798        for value in ["", "belize", "#12345", "#12345g", "123456"] {
799            assert!(!is_valid_card_color(value));
800        }
801    }
802
803    #[test]
804    fn response_objects_reject_unmapped_fields() {
805        assert!(
806            serde_json::from_value::<CardSummary>(json!({
807                "_id": "card-1",
808                "future": true
809            }))
810            .is_err()
811        );
812        assert!(
813            serde_json::from_value::<CardDocument>(json!({
814                "_id": "card-1",
815                "archived": false,
816                "swimlaneId": "swimlane-1",
817                "createdAt": "2030-01-02T03:04:05Z",
818                "modifiedAt": "2030-01-02T03:04:05Z",
819                "dateLastActivity": "2030-01-02T03:04:05Z",
820                "userId": "user-1",
821                "type": "cardType-card",
822                "showActivities": false,
823                "vote": {"future": true}
824            }))
825            .is_err()
826        );
827    }
828}