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