1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
10#[serde(rename_all = "lowercase")]
11pub enum BoardPermission {
12 #[default]
13 Private,
14 Public,
15}
16
17impl BoardPermission {
18 pub(crate) const fn as_str(self) -> &'static str {
19 match self {
20 Self::Private => "private",
21 Self::Public => "public",
22 }
23 }
24}
25
26#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(rename_all = "lowercase")]
28pub enum BoardColor {
29 #[default]
30 Belize,
31 Nephritis,
32 Pomegranate,
33 Pumpkin,
34 Wisteria,
35 Moderatepink,
36 Strongcyan,
37 Limegreen,
38 Midnight,
39 Dark,
40 Relax,
41 Corteza,
42 Appleglasspastel,
43 Clearblue,
44 Cleargreen,
45 Clearorange,
46 Clearpink,
47 Clearpurple,
48 Clearred,
49 Natural,
50 Modern,
51 Moderndark,
52 Exodark,
53 Cleandark,
54 Cleanlight,
55}
56
57impl BoardColor {
58 pub(crate) const fn as_str(self) -> &'static str {
59 match self {
60 Self::Belize => "belize",
61 Self::Nephritis => "nephritis",
62 Self::Pomegranate => "pomegranate",
63 Self::Pumpkin => "pumpkin",
64 Self::Wisteria => "wisteria",
65 Self::Moderatepink => "moderatepink",
66 Self::Strongcyan => "strongcyan",
67 Self::Limegreen => "limegreen",
68 Self::Midnight => "midnight",
69 Self::Dark => "dark",
70 Self::Relax => "relax",
71 Self::Corteza => "corteza",
72 Self::Appleglasspastel => "appleglasspastel",
73 Self::Clearblue => "clearblue",
74 Self::Cleargreen => "cleargreen",
75 Self::Clearorange => "clearorange",
76 Self::Clearpink => "clearpink",
77 Self::Clearpurple => "clearpurple",
78 Self::Clearred => "clearred",
79 Self::Natural => "natural",
80 Self::Modern => "modern",
81 Self::Moderndark => "moderndark",
82 Self::Exodark => "exodark",
83 Self::Cleandark => "cleandark",
84 Self::Cleanlight => "cleanlight",
85 }
86 }
87}
88
89#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
90#[serde(rename_all = "kebab-case")]
91pub enum PresentParentTask {
92 PrefixWithFullPath,
93 PrefixWithParent,
94 SubtextWithFullPath,
95 SubtextWithParent,
96 NoParent,
97}
98
99impl PresentParentTask {
100 pub(crate) const fn as_str(self) -> &'static str {
101 match self {
102 Self::PrefixWithFullPath => "prefix-with-full-path",
103 Self::PrefixWithParent => "prefix-with-parent",
104 Self::SubtextWithFullPath => "subtext-with-full-path",
105 Self::SubtextWithParent => "subtext-with-parent",
106 Self::NoParent => "no-parent",
107 }
108 }
109}
110
111#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
112#[serde(rename_all = "kebab-case")]
113pub enum BoardType {
114 Board,
115 TemplateBoard,
116 TemplateContainer,
117}
118
119impl BoardType {
120 pub(crate) const fn as_str(self) -> &'static str {
121 match self {
122 Self::Board => "board",
123 Self::TemplateBoard => "template-board",
124 Self::TemplateContainer => "template-container",
125 }
126 }
127}
128
129#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
130#[serde(rename_all = "lowercase")]
131pub enum BoardWatchLevel {
132 Watching,
133 Tracking,
134 Muted,
135}
136
137impl BoardWatchLevel {
138 pub(crate) const fn as_str(self) -> &'static str {
139 match self {
140 Self::Watching => "watching",
141 Self::Tracking => "tracking",
142 Self::Muted => "muted",
143 }
144 }
145}
146
147#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct CreateBoardRequest {
150 #[serde(
151 skip_serializing_if = "is_true",
152 serialize_with = "serialize_owner_flag"
153 )]
154 pub is_admin: bool,
155 #[serde(
156 skip_serializing_if = "is_true",
157 serialize_with = "serialize_owner_flag"
158 )]
159 pub is_active: bool,
160 pub title: String,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 pub owner: Option<String>,
163 pub permission: BoardPermission,
164 pub color: BoardColor,
165 #[serde(skip_serializing_if = "is_false")]
166 pub is_no_comments: bool,
167 #[serde(skip_serializing_if = "is_false")]
168 pub is_comment_only: bool,
169 #[serde(skip_serializing_if = "is_false")]
170 pub is_worker: bool,
171}
172
173const fn is_false(value: &bool) -> bool {
174 !*value
175}
176
177const fn is_true(value: &bool) -> bool {
178 *value
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub struct CreateBoardResult {
183 pub board_id: String,
184 pub default_swimlane_id: String,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
188pub struct RenameBoardResult {
189 pub board_id: String,
190 pub title: String,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct DeleteBoardResult {
195 pub board_id: String,
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(deny_unknown_fields)]
200pub struct BoardCounts {
201 pub private: u64,
202 pub public: u64,
203}
204
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206#[serde(deny_unknown_fields, rename_all = "camelCase")]
207pub struct BoardSummary {
208 #[serde(rename = "_id")]
209 pub board_id: String,
210 pub title: String,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214#[serde(
215 deny_unknown_fields,
216 rename_all(deserialize = "camelCase", serialize = "snake_case")
217)]
218pub struct BoardMember {
219 pub user_id: String,
220 pub is_admin: bool,
221 pub is_active: bool,
222 pub is_no_comments: Option<bool>,
223 pub is_comment_only: Option<bool>,
224 pub is_worker: Option<bool>,
225 pub is_normal_assigned_only: Option<bool>,
226 pub is_comment_assigned_only: Option<bool>,
227 pub is_read_only: Option<bool>,
228 pub is_read_assigned_only: Option<bool>,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
232#[serde(
233 deny_unknown_fields,
234 rename_all(deserialize = "camelCase", serialize = "snake_case")
235)]
236pub struct BoardWatcher {
237 pub user_id: String,
238 pub level: BoardWatchLevel,
239}
240
241#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
242#[serde(
243 deny_unknown_fields,
244 rename_all(deserialize = "camelCase", serialize = "snake_case")
245)]
246pub struct BoardLabel {
247 #[serde(rename(deserialize = "_id", serialize = "label_id"))]
248 pub label_id: String,
249 pub name: Option<String>,
250 pub color: String,
251}
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254#[serde(
255 deny_unknown_fields,
256 rename_all(deserialize = "camelCase", serialize = "snake_case")
257)]
258pub struct BoardOrganization {
259 pub org_id: String,
260 pub org_display_name: String,
261 pub is_active: bool,
262}
263
264#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
265#[serde(
266 deny_unknown_fields,
267 rename_all(deserialize = "camelCase", serialize = "snake_case")
268)]
269pub struct BoardTeam {
270 pub team_id: String,
271 pub team_display_name: String,
272 pub is_active: bool,
273}
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276#[serde(
277 deny_unknown_fields,
278 rename_all(deserialize = "camelCase", serialize = "snake_case")
279)]
280pub struct BoardDomain {
281 pub domain: String,
282 pub is_active: bool,
283}
284
285#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
286#[serde(
287 deny_unknown_fields,
288 rename_all(deserialize = "camelCase", serialize = "snake_case")
289)]
290pub struct BoardDocument {
291 #[serde(rename(deserialize = "_id", serialize = "board_id"))]
292 pub board_id: String,
293 pub title: String,
294 pub slug: Option<String>,
295 pub archived: Option<bool>,
296 pub archived_at: Option<String>,
297 pub created_at: Option<String>,
298 pub modified_at: Option<String>,
299 pub stars: Option<Number>,
300 #[serde(default)]
301 pub labels: Vec<BoardLabel>,
302 pub members: Vec<BoardMember>,
303 #[serde(default)]
304 pub watchers: Vec<BoardWatcher>,
305 pub permission: Option<BoardPermission>,
306 #[serde(default)]
307 pub orgs: Vec<BoardOrganization>,
308 #[serde(default)]
309 pub teams: Vec<BoardTeam>,
310 #[serde(default)]
311 pub domains: Vec<BoardDomain>,
312 #[serde(default)]
313 pub import_usernames: Vec<String>,
314 pub color: Option<BoardColor>,
315 #[serde(default)]
316 pub custom_theme_colors: Vec<String>,
317 #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
318 pub background_image_url: Option<String>,
319 pub background_image_id: Option<String>,
320 pub allows_card_counter_list: Option<bool>,
321 pub card_aging: Option<bool>,
322 pub show_dependencies: Option<bool>,
323 pub card_aging_days1: Option<Number>,
324 pub card_aging_days2: Option<Number>,
325 pub card_aging_days3: Option<Number>,
326 pub allows_board_member_list: Option<bool>,
327 pub description: Option<String>,
328 pub subtasks_default_board_id: Option<String>,
329 pub migration_version: Option<Number>,
330 pub subtasks_default_list_id: Option<String>,
331 pub date_settings_default_board_id: Option<String>,
332 pub date_settings_default_list_id: Option<String>,
333 pub allows_subtasks: Option<bool>,
334 pub allows_subtasks_on_minicard: Option<bool>,
335 pub allows_attachments: Option<bool>,
336 pub allows_attachments_on_minicard: Option<bool>,
337 pub allows_checklists: Option<bool>,
338 pub allows_checklists_on_minicard: Option<bool>,
339 pub allows_custom_fields: Option<bool>,
340 pub allows_custom_fields_on_minicard: Option<bool>,
341 pub allows_checklist_count_badge_on_minicard: Option<bool>,
342 pub allows_comments: Option<bool>,
343 pub allows_description_title: Option<bool>,
344 pub allows_description_title_on_minicard: Option<bool>,
345 pub allows_description_text: Option<bool>,
346 pub allows_description_text_on_minicard: Option<bool>,
347 pub allows_cover_attachment_on_minicard: Option<bool>,
348 pub allows_cover_attachment_on_card: Option<bool>,
349 pub allows_badge_attachment_on_minicard: Option<bool>,
350 pub allows_attachment_count_on_card: Option<bool>,
351 pub allows_checklist_count_badge_on_card: Option<bool>,
352 pub allows_card_sorting_by_number_on_minicard: Option<bool>,
353 pub allows_card_number: Option<bool>,
354 pub allows_card_number_on_minicard: Option<bool>,
355 pub allows_activities: Option<bool>,
356 pub allows_labels: Option<bool>,
357 pub allows_labels_on_minicard: Option<bool>,
358 pub allows_creator: Option<bool>,
359 pub allows_creator_on_minicard: Option<bool>,
360 pub allows_assignee: Option<bool>,
361 pub allows_assignee_on_minicard: Option<bool>,
362 pub allows_members: Option<bool>,
363 pub allows_members_on_minicard: Option<bool>,
364 pub allows_requested_by: Option<bool>,
365 pub allows_requested_by_on_minicard: Option<bool>,
366 pub allows_card_sorting_by_number: Option<bool>,
367 pub allows_show_lists: Option<bool>,
368 pub allows_assigned_by: Option<bool>,
369 pub allows_assigned_by_on_minicard: Option<bool>,
370 pub allows_show_lists_on_minicard: Option<bool>,
371 pub allows_checklist_at_minicard: Option<bool>,
372 pub allows_received_date: Option<bool>,
373 pub restrict_comment_editing: Option<bool>,
374 pub allows_personal_list_width: Option<bool>,
375 pub auto_width: Option<bool>,
376 pub allows_received_date_on_minicard: Option<bool>,
377 pub allows_start_date: Option<bool>,
378 pub allows_start_date_on_minicard: Option<bool>,
379 pub allows_end_date: Option<bool>,
380 pub allows_end_date_on_minicard: Option<bool>,
381 pub allows_due_date: Option<bool>,
382 pub allows_due_date_on_minicard: Option<bool>,
383 pub allows_due_complete: Option<bool>,
384 pub allows_due_complete_on_minicard: Option<bool>,
385 pub present_parent_task: Option<PresentParentTask>,
386 pub received_at: Option<String>,
387 pub start_at: Option<String>,
388 pub due_at: Option<String>,
389 pub end_at: Option<String>,
390 pub spent_time: Option<Number>,
391 pub is_overtime: Option<bool>,
392 #[serde(rename(deserialize = "type", serialize = "board_type"))]
393 pub board_type: Option<BoardType>,
394 pub sort: Option<Number>,
395 pub show_activities: Option<bool>,
396}
397
398#[derive(Deserialize)]
399#[serde(deny_unknown_fields, rename_all = "camelCase")]
400struct CreateBoardResponse {
401 #[serde(rename = "_id")]
402 board_id: String,
403 default_swimlane_id: String,
404}
405
406#[derive(Serialize)]
407struct RenameBoardRequest<'a> {
408 title: &'a str,
409}
410
411#[derive(Deserialize)]
412#[serde(deny_unknown_fields)]
413struct RenameBoardResponse {
414 #[serde(rename = "_id")]
415 board_id: String,
416 title: String,
417}
418
419#[derive(Deserialize)]
420#[serde(deny_unknown_fields)]
421struct IdResponse {
422 #[serde(rename = "_id")]
423 id: String,
424}
425
426impl WekanClient {
427 pub async fn public_boards(
428 &self,
429 token: &SecretString,
430 ) -> Result<Vec<BoardSummary>, ClientError> {
431 let body = self
432 .execute_success_request(
433 self.http
434 .get(self.boards_api_endpoint("api/boards", "public boards")?)
435 .header(ACCEPT, "application/json")
436 .bearer_auth(token.expose_secret()),
437 )
438 .await?;
439 let boards = decode_success(&body, "public boards", true)?;
440 validate_board_summaries(boards, "public boards")
441 }
442
443 pub async fn board_counts(&self, token: &SecretString) -> Result<BoardCounts, ClientError> {
444 let body = self
445 .execute_success_request(
446 self.http
447 .get(self.boards_api_endpoint("api/boards_count", "board counts")?)
448 .header(ACCEPT, "application/json")
449 .bearer_auth(token.expose_secret()),
450 )
451 .await?;
452 decode_success(&body, "board counts", true)
453 }
454
455 pub async fn board(
456 &self,
457 board_id: &str,
458 token: &SecretString,
459 ) -> Result<BoardDocument, ClientError> {
460 let body = self
461 .execute_success_request(
462 self.http
463 .get(self.board_endpoint(board_id, None)?)
464 .header(ACCEPT, "application/json")
465 .bearer_auth(token.expose_secret()),
466 )
467 .await?;
468 let board = decode_success(&body, "board", true)?;
469 validate_board_document(board)
470 }
471
472 pub async fn create_board(
473 &self,
474 request: &CreateBoardRequest,
475 token: &SecretString,
476 ) -> Result<CreateBoardResult, ClientError> {
477 let body = self
478 .execute_success_request(
479 self.http
480 .post(self.boards_api_endpoint("api/boards", "board creation")?)
481 .header(ACCEPT, "application/json")
482 .bearer_auth(token.expose_secret())
483 .json(request),
484 )
485 .await?;
486 let response: CreateBoardResponse = decode_success(&body, "board creation", true)?;
487 require_non_empty(&response.board_id, "board creation", "board id")?;
488 require_non_empty(
489 &response.default_swimlane_id,
490 "board creation",
491 "default swimlane id",
492 )?;
493 Ok(CreateBoardResult {
494 board_id: response.board_id,
495 default_swimlane_id: response.default_swimlane_id,
496 })
497 }
498
499 pub async fn rename_board(
500 &self,
501 board_id: &str,
502 title: &str,
503 token: &SecretString,
504 ) -> Result<RenameBoardResult, ClientError> {
505 let body = self
506 .execute_success_request(
507 self.http
508 .put(self.board_endpoint(board_id, Some("title"))?)
509 .header(ACCEPT, "application/json")
510 .bearer_auth(token.expose_secret())
511 .json(&RenameBoardRequest { title }),
512 )
513 .await?;
514 let response: RenameBoardResponse = decode_success(&body, "board rename", true)?;
515 require_non_empty(&response.board_id, "board rename", "board id")?;
516 require_non_empty(&response.title, "board rename", "title")?;
517 Ok(RenameBoardResult {
518 board_id: response.board_id,
519 title: response.title,
520 })
521 }
522
523 pub async fn delete_board(
524 &self,
525 board_id: &str,
526 token: &SecretString,
527 ) -> Result<DeleteBoardResult, ClientError> {
528 let body = self
529 .execute_success_request(
530 self.http
531 .delete(self.board_endpoint(board_id, None)?)
532 .header(ACCEPT, "application/json")
533 .bearer_auth(token.expose_secret()),
534 )
535 .await?;
536 let response: IdResponse = decode_success(&body, "board deletion", true)?;
537 require_non_empty(&response.id, "board deletion", "board id")?;
538 Ok(DeleteBoardResult {
539 board_id: response.id,
540 })
541 }
542
543 fn boards_api_endpoint(
544 &self,
545 path: &str,
546 operation: &str,
547 ) -> Result<reqwest::Url, ClientError> {
548 self.server()
549 .join(path)
550 .map_err(|error| ClientError::Protocol {
551 diagnostic: None,
552 message: format!("could not build the {operation} endpoint: {error}"),
553 success_status_received: false,
554 })
555 }
556
557 fn board_endpoint(
558 &self,
559 board_id: &str,
560 suffix: Option<&str>,
561 ) -> Result<reqwest::Url, ClientError> {
562 let mut endpoint = self.boards_api_endpoint("api/boards", "board")?;
563 let mut segments = endpoint
564 .path_segments_mut()
565 .map_err(|()| ClientError::Protocol {
566 diagnostic: None,
567 message: "could not add the board id to the endpoint".to_owned(),
568 success_status_received: false,
569 })?;
570 segments.push(board_id);
571 if let Some(suffix) = suffix {
572 segments.push(suffix);
573 }
574 drop(segments);
575 Ok(endpoint)
576 }
577}
578
579fn validate_board_summaries(
580 boards: Vec<BoardSummary>,
581 operation: &str,
582) -> Result<Vec<BoardSummary>, ClientError> {
583 for board in &boards {
584 require_non_empty(&board.board_id, operation, "board id")?;
585 require_non_empty(&board.title, operation, "title")?;
586 }
587 Ok(boards)
588}
589
590fn validate_board_document(board: BoardDocument) -> Result<BoardDocument, ClientError> {
591 require_non_empty(&board.board_id, "board", "board id")?;
592 require_non_empty(&board.title, "board", "title")?;
593 for (field, value) in [
594 ("archivedAt", board.archived_at.as_deref()),
595 ("createdAt", board.created_at.as_deref()),
596 ("modifiedAt", board.modified_at.as_deref()),
597 ("receivedAt", board.received_at.as_deref()),
598 ("startAt", board.start_at.as_deref()),
599 ("dueAt", board.due_at.as_deref()),
600 ("endAt", board.end_at.as_deref()),
601 ] {
602 if let Some(value) = value {
603 validate_date_time(value, field)?;
604 }
605 }
606 Ok(board)
607}
608
609fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
610 OffsetDateTime::parse(value, &Rfc3339)
611 .map(|_| ())
612 .map_err(|_| ClientError::Protocol {
613 diagnostic: None,
614 message: format!("the board response contained an invalid {field} date-time"),
615 success_status_received: true,
616 })
617}
618
619fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
620 if value.is_empty() {
621 Err(ClientError::Protocol {
622 diagnostic: None,
623 message: format!("the {operation} response contained an empty {field}"),
624 success_status_received: true,
625 })
626 } else {
627 Ok(())
628 }
629}
630
631fn serialize_owner_flag<S: serde::Serializer>(
632 value: &bool,
633 serializer: S,
634) -> Result<S::Ok, S::Error> {
635 serializer.serialize_str(if *value { "true" } else { "false" })
636}