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