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 pub title: String,
151 #[serde(skip_serializing_if = "Option::is_none")]
152 pub owner: Option<String>,
153 pub permission: BoardPermission,
154 pub color: BoardColor,
155 #[serde(skip_serializing_if = "is_false")]
156 pub is_no_comments: bool,
157 #[serde(skip_serializing_if = "is_false")]
158 pub is_comment_only: bool,
159 #[serde(skip_serializing_if = "is_false")]
160 pub is_worker: bool,
161}
162
163const fn is_false(value: &bool) -> bool {
164 !*value
165}
166
167#[derive(Clone, Debug, Eq, PartialEq)]
168pub struct CreateBoardResult {
169 pub board_id: String,
170 pub default_swimlane_id: String,
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub struct RenameBoardResult {
175 pub board_id: String,
176 pub title: String,
177}
178
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub struct DeleteBoardResult {
181 pub board_id: String,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct BoardCounts {
187 pub private: u64,
188 pub public: u64,
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192#[serde(deny_unknown_fields, rename_all = "camelCase")]
193pub struct BoardSummary {
194 #[serde(rename = "_id")]
195 pub board_id: String,
196 pub title: String,
197}
198
199#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
200#[serde(
201 deny_unknown_fields,
202 rename_all(deserialize = "camelCase", serialize = "snake_case")
203)]
204pub struct BoardMember {
205 pub user_id: String,
206 pub is_admin: bool,
207 pub is_active: bool,
208 pub is_no_comments: Option<bool>,
209 pub is_comment_only: Option<bool>,
210 pub is_worker: Option<bool>,
211 pub is_normal_assigned_only: Option<bool>,
212 pub is_comment_assigned_only: Option<bool>,
213 pub is_read_only: Option<bool>,
214 pub is_read_assigned_only: Option<bool>,
215}
216
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218#[serde(
219 deny_unknown_fields,
220 rename_all(deserialize = "camelCase", serialize = "snake_case")
221)]
222pub struct BoardWatcher {
223 pub user_id: String,
224 pub level: BoardWatchLevel,
225}
226
227#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228#[serde(
229 deny_unknown_fields,
230 rename_all(deserialize = "camelCase", serialize = "snake_case")
231)]
232pub struct BoardLabel {
233 #[serde(rename(deserialize = "_id", serialize = "label_id"))]
234 pub label_id: String,
235 pub name: Option<String>,
236 pub color: String,
237}
238
239#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
240#[serde(
241 deny_unknown_fields,
242 rename_all(deserialize = "camelCase", serialize = "snake_case")
243)]
244pub struct BoardOrganization {
245 pub org_id: String,
246 pub org_display_name: String,
247 pub is_active: bool,
248}
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
251#[serde(
252 deny_unknown_fields,
253 rename_all(deserialize = "camelCase", serialize = "snake_case")
254)]
255pub struct BoardTeam {
256 pub team_id: String,
257 pub team_display_name: String,
258 pub is_active: bool,
259}
260
261#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
262#[serde(
263 deny_unknown_fields,
264 rename_all(deserialize = "camelCase", serialize = "snake_case")
265)]
266pub struct BoardDomain {
267 pub domain: String,
268 pub is_active: bool,
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(
273 deny_unknown_fields,
274 rename_all(deserialize = "camelCase", serialize = "snake_case")
275)]
276pub struct BoardDocument {
277 #[serde(rename(deserialize = "_id", serialize = "board_id"))]
278 pub board_id: String,
279 pub title: String,
280 pub slug: Option<String>,
281 pub archived: Option<bool>,
282 pub archived_at: Option<String>,
283 pub created_at: Option<String>,
284 pub modified_at: Option<String>,
285 pub stars: Option<Number>,
286 #[serde(default)]
287 pub labels: Vec<BoardLabel>,
288 pub members: Vec<BoardMember>,
289 #[serde(default)]
290 pub watchers: Vec<BoardWatcher>,
291 pub permission: Option<BoardPermission>,
292 #[serde(default)]
293 pub orgs: Vec<BoardOrganization>,
294 #[serde(default)]
295 pub teams: Vec<BoardTeam>,
296 #[serde(default)]
297 pub domains: Vec<BoardDomain>,
298 #[serde(default)]
299 pub import_usernames: Vec<String>,
300 pub color: Option<BoardColor>,
301 #[serde(default)]
302 pub custom_theme_colors: Vec<String>,
303 #[serde(rename(deserialize = "backgroundImageURL", serialize = "background_image_url"))]
304 pub background_image_url: Option<String>,
305 pub background_image_id: Option<String>,
306 pub allows_card_counter_list: Option<bool>,
307 pub card_aging: Option<bool>,
308 pub show_dependencies: Option<bool>,
309 pub card_aging_days1: Option<Number>,
310 pub card_aging_days2: Option<Number>,
311 pub card_aging_days3: Option<Number>,
312 pub allows_board_member_list: Option<bool>,
313 pub description: Option<String>,
314 pub subtasks_default_board_id: Option<String>,
315 pub migration_version: Option<Number>,
316 pub subtasks_default_list_id: Option<String>,
317 pub date_settings_default_board_id: Option<String>,
318 pub date_settings_default_list_id: Option<String>,
319 pub allows_subtasks: Option<bool>,
320 pub allows_subtasks_on_minicard: Option<bool>,
321 pub allows_attachments: Option<bool>,
322 pub allows_attachments_on_minicard: Option<bool>,
323 pub allows_checklists: Option<bool>,
324 pub allows_checklists_on_minicard: Option<bool>,
325 pub allows_custom_fields: Option<bool>,
326 pub allows_custom_fields_on_minicard: Option<bool>,
327 pub allows_checklist_count_badge_on_minicard: Option<bool>,
328 pub allows_comments: Option<bool>,
329 pub allows_description_title: Option<bool>,
330 pub allows_description_title_on_minicard: Option<bool>,
331 pub allows_description_text: Option<bool>,
332 pub allows_description_text_on_minicard: Option<bool>,
333 pub allows_cover_attachment_on_minicard: Option<bool>,
334 pub allows_cover_attachment_on_card: Option<bool>,
335 pub allows_badge_attachment_on_minicard: Option<bool>,
336 pub allows_attachment_count_on_card: Option<bool>,
337 pub allows_checklist_count_badge_on_card: Option<bool>,
338 pub allows_card_sorting_by_number_on_minicard: Option<bool>,
339 pub allows_card_number: Option<bool>,
340 pub allows_card_number_on_minicard: Option<bool>,
341 pub allows_activities: Option<bool>,
342 pub allows_labels: Option<bool>,
343 pub allows_labels_on_minicard: Option<bool>,
344 pub allows_creator: Option<bool>,
345 pub allows_creator_on_minicard: Option<bool>,
346 pub allows_assignee: Option<bool>,
347 pub allows_assignee_on_minicard: Option<bool>,
348 pub allows_members: Option<bool>,
349 pub allows_members_on_minicard: Option<bool>,
350 pub allows_requested_by: Option<bool>,
351 pub allows_requested_by_on_minicard: Option<bool>,
352 pub allows_card_sorting_by_number: Option<bool>,
353 pub allows_show_lists: Option<bool>,
354 pub allows_assigned_by: Option<bool>,
355 pub allows_assigned_by_on_minicard: Option<bool>,
356 pub allows_show_lists_on_minicard: Option<bool>,
357 pub allows_checklist_at_minicard: Option<bool>,
358 pub allows_received_date: Option<bool>,
359 pub restrict_comment_editing: Option<bool>,
360 pub allows_personal_list_width: Option<bool>,
361 pub auto_width: Option<bool>,
362 pub allows_received_date_on_minicard: Option<bool>,
363 pub allows_start_date: Option<bool>,
364 pub allows_start_date_on_minicard: Option<bool>,
365 pub allows_end_date: Option<bool>,
366 pub allows_end_date_on_minicard: Option<bool>,
367 pub allows_due_date: Option<bool>,
368 pub allows_due_date_on_minicard: Option<bool>,
369 pub allows_due_complete: Option<bool>,
370 pub allows_due_complete_on_minicard: Option<bool>,
371 pub present_parent_task: Option<PresentParentTask>,
372 pub received_at: Option<String>,
373 pub start_at: Option<String>,
374 pub due_at: Option<String>,
375 pub end_at: Option<String>,
376 pub spent_time: Option<Number>,
377 pub is_overtime: Option<bool>,
378 #[serde(rename(deserialize = "type", serialize = "board_type"))]
379 pub board_type: Option<BoardType>,
380 pub sort: Option<Number>,
381 pub show_activities: Option<bool>,
382}
383
384#[derive(Deserialize)]
385#[serde(deny_unknown_fields, rename_all = "camelCase")]
386struct CreateBoardResponse {
387 #[serde(rename = "_id")]
388 board_id: String,
389 default_swimlane_id: String,
390}
391
392#[derive(Serialize)]
393struct RenameBoardRequest<'a> {
394 title: &'a str,
395}
396
397#[derive(Deserialize)]
398#[serde(deny_unknown_fields)]
399struct RenameBoardResponse {
400 #[serde(rename = "_id")]
401 board_id: String,
402 title: String,
403}
404
405#[derive(Deserialize)]
406#[serde(deny_unknown_fields)]
407struct IdResponse {
408 #[serde(rename = "_id")]
409 id: String,
410}
411
412impl WekanClient {
413 pub async fn public_boards(
414 &self,
415 token: &SecretString,
416 ) -> Result<Vec<BoardSummary>, ClientError> {
417 let body = self
418 .execute_success_request(
419 self.http
420 .get(self.boards_api_endpoint("api/boards", "public boards")?)
421 .header(ACCEPT, "application/json")
422 .bearer_auth(token.expose_secret()),
423 )
424 .await?;
425 let boards = decode_success(&body, "public boards", true)?;
426 validate_board_summaries(boards, "public boards")
427 }
428
429 pub async fn board_counts(&self, token: &SecretString) -> Result<BoardCounts, ClientError> {
430 let body = self
431 .execute_success_request(
432 self.http
433 .get(self.boards_api_endpoint("api/boards_count", "board counts")?)
434 .header(ACCEPT, "application/json")
435 .bearer_auth(token.expose_secret()),
436 )
437 .await?;
438 decode_success(&body, "board counts", true)
439 }
440
441 pub async fn board(
442 &self,
443 board_id: &str,
444 token: &SecretString,
445 ) -> Result<BoardDocument, ClientError> {
446 let body = self
447 .execute_success_request(
448 self.http
449 .get(self.board_endpoint(board_id, None)?)
450 .header(ACCEPT, "application/json")
451 .bearer_auth(token.expose_secret()),
452 )
453 .await?;
454 let board = decode_success(&body, "board", true)?;
455 validate_board_document(board)
456 }
457
458 pub async fn create_board(
459 &self,
460 request: &CreateBoardRequest,
461 token: &SecretString,
462 ) -> Result<CreateBoardResult, ClientError> {
463 let body = self
464 .execute_success_request(
465 self.http
466 .post(self.boards_api_endpoint("api/boards", "board creation")?)
467 .header(ACCEPT, "application/json")
468 .bearer_auth(token.expose_secret())
469 .json(request),
470 )
471 .await?;
472 let response: CreateBoardResponse = decode_success(&body, "board creation", true)?;
473 require_non_empty(&response.board_id, "board creation", "board id")?;
474 require_non_empty(
475 &response.default_swimlane_id,
476 "board creation",
477 "default swimlane id",
478 )?;
479 Ok(CreateBoardResult {
480 board_id: response.board_id,
481 default_swimlane_id: response.default_swimlane_id,
482 })
483 }
484
485 pub async fn rename_board(
486 &self,
487 board_id: &str,
488 title: &str,
489 token: &SecretString,
490 ) -> Result<RenameBoardResult, ClientError> {
491 let body = self
492 .execute_success_request(
493 self.http
494 .put(self.board_endpoint(board_id, Some("title"))?)
495 .header(ACCEPT, "application/json")
496 .bearer_auth(token.expose_secret())
497 .json(&RenameBoardRequest { title }),
498 )
499 .await?;
500 let response: RenameBoardResponse = decode_success(&body, "board rename", true)?;
501 require_non_empty(&response.board_id, "board rename", "board id")?;
502 require_non_empty(&response.title, "board rename", "title")?;
503 Ok(RenameBoardResult {
504 board_id: response.board_id,
505 title: response.title,
506 })
507 }
508
509 pub async fn delete_board(
510 &self,
511 board_id: &str,
512 token: &SecretString,
513 ) -> Result<DeleteBoardResult, ClientError> {
514 let body = self
515 .execute_success_request(
516 self.http
517 .delete(self.board_endpoint(board_id, None)?)
518 .header(ACCEPT, "application/json")
519 .bearer_auth(token.expose_secret()),
520 )
521 .await?;
522 let response: IdResponse = decode_success(&body, "board deletion", true)?;
523 require_non_empty(&response.id, "board deletion", "board id")?;
524 Ok(DeleteBoardResult {
525 board_id: response.id,
526 })
527 }
528
529 fn boards_api_endpoint(
530 &self,
531 path: &str,
532 operation: &str,
533 ) -> Result<reqwest::Url, ClientError> {
534 self.server()
535 .join(path)
536 .map_err(|error| ClientError::Protocol {
537 diagnostic: None,
538 message: format!("could not build the {operation} endpoint: {error}"),
539 success_status_received: false,
540 })
541 }
542
543 fn board_endpoint(
544 &self,
545 board_id: &str,
546 suffix: Option<&str>,
547 ) -> Result<reqwest::Url, ClientError> {
548 let mut endpoint = self.boards_api_endpoint("api/boards", "board")?;
549 let mut segments = endpoint
550 .path_segments_mut()
551 .map_err(|()| ClientError::Protocol {
552 diagnostic: None,
553 message: "could not add the board id to the endpoint".to_owned(),
554 success_status_received: false,
555 })?;
556 segments.push(board_id);
557 if let Some(suffix) = suffix {
558 segments.push(suffix);
559 }
560 drop(segments);
561 Ok(endpoint)
562 }
563}
564
565fn validate_board_summaries(
566 boards: Vec<BoardSummary>,
567 operation: &str,
568) -> Result<Vec<BoardSummary>, ClientError> {
569 for board in &boards {
570 require_non_empty(&board.board_id, operation, "board id")?;
571 require_non_empty(&board.title, operation, "title")?;
572 }
573 Ok(boards)
574}
575
576fn validate_board_document(board: BoardDocument) -> Result<BoardDocument, ClientError> {
577 require_non_empty(&board.board_id, "board", "board id")?;
578 require_non_empty(&board.title, "board", "title")?;
579 for (field, value) in [
580 ("archivedAt", board.archived_at.as_deref()),
581 ("createdAt", board.created_at.as_deref()),
582 ("modifiedAt", board.modified_at.as_deref()),
583 ("receivedAt", board.received_at.as_deref()),
584 ("startAt", board.start_at.as_deref()),
585 ("dueAt", board.due_at.as_deref()),
586 ("endAt", board.end_at.as_deref()),
587 ] {
588 if let Some(value) = value {
589 validate_date_time(value, field)?;
590 }
591 }
592 Ok(board)
593}
594
595fn validate_date_time(value: &str, field: &str) -> Result<(), ClientError> {
596 OffsetDateTime::parse(value, &Rfc3339)
597 .map(|_| ())
598 .map_err(|_| ClientError::Protocol {
599 diagnostic: None,
600 message: format!("the board response contained an invalid {field} date-time"),
601 success_status_received: true,
602 })
603}
604
605fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
606 if value.is_empty() {
607 Err(ClientError::Protocol {
608 diagnostic: None,
609 message: format!("the {operation} response contained an empty {field}"),
610 success_status_received: true,
611 })
612 } else {
613 Ok(())
614 }
615}