fenix-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Domain helpers for knowledge operations aligned with the Fênix Cloud API."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
12
+
13
+
14
+ def _strip_none(data: Dict[str, Any]) -> Dict[str, Any]:
15
+ return {key: value for key, value in data.items() if value not in (None, "")}
16
+
17
+
18
+ def _format_date(value: Optional[str]) -> str:
19
+ if not value:
20
+ return "não definido"
21
+ try:
22
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
23
+ return dt.strftime("%d/%m/%Y")
24
+ except ValueError:
25
+ return value
26
+
27
+
28
+ def _ensure_list(value: Any) -> List[Dict[str, Any]]:
29
+ if isinstance(value, list):
30
+ return [item for item in value if isinstance(item, dict)]
31
+ if isinstance(value, dict):
32
+ data = value.get("data")
33
+ if isinstance(data, list):
34
+ return [item for item in data if isinstance(item, dict)]
35
+ return []
36
+
37
+
38
+ def _ensure_dict(value: Any) -> Dict[str, Any]:
39
+ if isinstance(value, dict):
40
+ data = value.get("data")
41
+ if isinstance(data, dict):
42
+ return data
43
+ return value
44
+ return {}
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class KnowledgeService:
49
+ api: FenixApiClient
50
+ logger: Any
51
+
52
+ async def _call(self, func, *args, **kwargs):
53
+ return await asyncio.to_thread(func, *args, **kwargs)
54
+
55
+ async def _call_list(self, func, *args, **kwargs) -> List[Dict[str, Any]]:
56
+ result = await self._call(func, *args, **kwargs)
57
+ return _ensure_list(result)
58
+
59
+ async def _call_dict(self, func, *args, **kwargs) -> Dict[str, Any]:
60
+ result = await self._call(func, *args, **kwargs)
61
+ return _ensure_dict(result)
62
+
63
+ # ------------------------------------------------------------------
64
+ # Work items
65
+ # ------------------------------------------------------------------
66
+ async def work_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
67
+ return await self._call(self.api.create_work_item, _strip_none(payload))
68
+
69
+ async def work_list(self, **filters: Any) -> List[Dict[str, Any]]:
70
+ return await self._call_list(self.api.list_work_items, **_strip_none(filters))
71
+
72
+ async def work_get(self, work_id: str) -> Dict[str, Any]:
73
+ return await self._call_dict(self.api.get_work_item, work_id)
74
+
75
+ async def work_update(self, work_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
76
+ return await self._call(self.api.update_work_item, work_id, _strip_none(payload))
77
+
78
+ async def work_delete(self, work_id: str) -> None:
79
+ await self._call(self.api.delete_work_item, work_id)
80
+
81
+ async def work_backlog(self, *, team_id: str) -> List[Dict[str, Any]]:
82
+ return await self._call_list(self.api.list_work_items_backlog, team_id=team_id)
83
+
84
+ async def work_search(self, *, query: str, team_id: str, limit: int) -> List[Dict[str, Any]]:
85
+ return await self._call_list(
86
+ self.api.search_work_items,
87
+ query=query,
88
+ team_id=team_id,
89
+ limit=limit,
90
+ )
91
+
92
+ async def work_analytics(self, *, team_id: str) -> Dict[str, Any]:
93
+ return await self._call(self.api.get_work_items_analytics, team_id=team_id) or {}
94
+
95
+ async def work_velocity(self, *, team_id: str, sprints_count: int) -> Dict[str, Any]:
96
+ return await self._call(
97
+ self.api.get_work_items_velocity,
98
+ team_id=team_id,
99
+ sprints_count=sprints_count,
100
+ ) or {}
101
+
102
+ async def work_by_sprint(self, *, sprint_id: str) -> List[Dict[str, Any]]:
103
+ return await self._call_list(self.api.list_work_items_by_sprint, sprint_id=sprint_id)
104
+
105
+ async def work_burndown(self, *, sprint_id: str) -> Dict[str, Any]:
106
+ return await self._call(self.api.get_work_items_burndown, sprint_id=sprint_id) or {}
107
+
108
+ async def work_by_epic(self, *, epic_id: str) -> List[Dict[str, Any]]:
109
+ return await self._call(self.api.list_work_items_by_epic, epic_id=epic_id) or []
110
+
111
+ async def work_epic_progress(self, *, epic_id: str) -> Dict[str, Any]:
112
+ return await self._call(self.api.get_work_items_epic_progress, epic_id=epic_id) or {}
113
+
114
+ async def work_by_board(self, *, board_id: str) -> List[Dict[str, Any]]:
115
+ return await self._call_list(self.api.list_work_items_by_board, board_id=board_id)
116
+
117
+ async def work_children(self, work_id: str) -> List[Dict[str, Any]]:
118
+ return await self._call_list(self.api.get_work_item_children, work_id)
119
+
120
+ async def work_move(self, work_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
121
+ return await self._call(self.api.move_work_item, work_id, _strip_none(payload))
122
+
123
+ async def work_update_status(self, work_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
124
+ return await self._call(self.api.update_work_item_status, work_id, _strip_none(payload))
125
+
126
+ async def work_move_to_board(self, work_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
127
+ return await self._call(self.api.move_work_item_to_board, work_id, _strip_none(payload))
128
+
129
+ async def work_link(self, work_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
130
+ return await self._call(self.api.link_work_item, work_id, _strip_none(payload))
131
+
132
+ async def work_assign_to_sprint(self, payload: Dict[str, Any]) -> Dict[str, Any]:
133
+ return await self._call(self.api.assign_work_items_to_sprint, _strip_none(payload))
134
+
135
+ async def work_bulk_update(self, payload: Dict[str, Any]) -> Dict[str, Any]:
136
+ return await self._call(self.api.bulk_update_work_items, _strip_none(payload))
137
+
138
+ # ------------------------------------------------------------------
139
+ # Work boards
140
+ # ------------------------------------------------------------------
141
+ async def board_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
142
+ return await self._call(self.api.create_work_board, _strip_none(payload))
143
+
144
+ async def board_list(self, **filters: Any) -> List[Dict[str, Any]]:
145
+ result = await self._call(self.api.list_work_boards, **_strip_none(filters))
146
+ return _ensure_list(result)
147
+
148
+ async def board_list_by_team(self, team_id: str) -> List[Dict[str, Any]]:
149
+ result = await self._call(self.api.list_work_boards_by_team, team_id=team_id)
150
+ return _ensure_list(result)
151
+
152
+ async def board_favorites(self) -> List[Dict[str, Any]]:
153
+ result = await self._call(self.api.list_favorite_work_boards)
154
+ return _ensure_list(result)
155
+
156
+ async def board_search(self, *, query: str, team_id: str, limit: int) -> List[Dict[str, Any]]:
157
+ result = await self._call(
158
+ self.api.search_work_boards,
159
+ query=query,
160
+ team_id=team_id,
161
+ limit=limit,
162
+ )
163
+ return _ensure_list(result)
164
+
165
+ async def board_recent(self, *, limit: int) -> List[Dict[str, Any]]:
166
+ result = await self._call(self.api.list_recent_work_boards, limit=limit)
167
+ return _ensure_list(result)
168
+
169
+ async def board_get(self, board_id: str) -> Dict[str, Any]:
170
+ result = await self._call(self.api.get_work_board, board_id)
171
+ return _ensure_dict(result)
172
+
173
+ async def board_update(self, board_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
174
+ return await self._call(self.api.update_work_board, board_id, _strip_none(payload))
175
+
176
+ async def board_delete(self, board_id: str) -> None:
177
+ await self._call(self.api.delete_work_board, board_id)
178
+
179
+ async def board_analytics(self, board_id: str) -> Dict[str, Any]:
180
+ return await self._call(self.api.get_work_board_analytics, board_id) or {}
181
+
182
+ async def board_columns(self, board_id: str) -> List[Dict[str, Any]]:
183
+ result = await self._call(self.api.list_work_board_columns, board_id)
184
+ return _ensure_list(result)
185
+
186
+ async def board_toggle_favorite(self, board_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
187
+ return await self._call(self.api.toggle_work_board_favorite, board_id, _strip_none(payload))
188
+
189
+ async def board_clone(self, board_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
190
+ return await self._call(self.api.clone_work_board, board_id, _strip_none(payload))
191
+
192
+ async def board_reorder(self, payload: Dict[str, Any]) -> Dict[str, Any]:
193
+ return await self._call(self.api.reorder_work_boards, _strip_none(payload))
194
+
195
+ async def board_column_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
196
+ return await self._call(self.api.create_work_board_column, _strip_none(payload))
197
+
198
+ async def board_column_reorder(self, payload: Dict[str, Any]) -> Dict[str, Any]:
199
+ return await self._call(self.api.reorder_work_board_columns, _strip_none(payload))
200
+
201
+ async def board_column_get(self, column_id: str) -> Dict[str, Any]:
202
+ return await self._call(self.api.get_work_board_column, column_id)
203
+
204
+ async def board_column_update(self, column_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
205
+ return await self._call(self.api.update_work_board_column, column_id, _strip_none(payload))
206
+
207
+ async def board_column_delete(self, column_id: str) -> None:
208
+ await self._call(self.api.delete_work_board_column, column_id)
209
+
210
+ # ------------------------------------------------------------------
211
+ # Sprints
212
+ # ------------------------------------------------------------------
213
+ async def sprint_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
214
+ return await self._call(self.api.create_sprint, _strip_none(payload))
215
+
216
+ async def sprint_list(self, **filters: Any) -> List[Dict[str, Any]]:
217
+ return await self._call_list(self.api.list_sprints, **_strip_none(filters))
218
+
219
+ async def sprint_get(self, sprint_id: str) -> Dict[str, Any]:
220
+ return await self._call_dict(self.api.get_sprint, sprint_id)
221
+
222
+ async def sprint_update(self, sprint_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
223
+ return await self._call(self.api.update_sprint, sprint_id, _strip_none(payload))
224
+
225
+ async def sprint_delete(self, sprint_id: str) -> None:
226
+ await self._call(self.api.delete_sprint, sprint_id)
227
+
228
+ async def sprint_list_by_team(self, team_id: str) -> List[Dict[str, Any]]:
229
+ return await self._call_list(self.api.list_sprints_by_team, team_id=team_id)
230
+
231
+ async def sprint_recent(self, *, team_id: str, limit: int) -> List[Dict[str, Any]]:
232
+ return await self._call(self.api.list_recent_sprints, team_id=team_id, limit=limit) or []
233
+
234
+ async def sprint_search(self, *, query: str, team_id: str, limit: int) -> List[Dict[str, Any]]:
235
+ return await self._call_list(
236
+ self.api.search_sprints,
237
+ query=query,
238
+ team_id=team_id,
239
+ limit=limit,
240
+ )
241
+
242
+ async def sprint_active(self, team_id: str) -> Dict[str, Any]:
243
+ return await self._call(self.api.get_active_sprint, team_id=team_id) or {}
244
+
245
+ async def sprint_velocity(self) -> Dict[str, Any]:
246
+ return await self._call(self.api.get_sprints_velocity) or {}
247
+
248
+ async def sprint_burndown(self) -> Dict[str, Any]:
249
+ return await self._call(self.api.get_sprints_burndown) or {}
250
+
251
+ async def sprint_work_items(self, sprint_id: str) -> List[Dict[str, Any]]:
252
+ return await self._call_list(self.api.get_sprint_work_items, sprint_id)
253
+
254
+ async def sprint_add_work_items(self, sprint_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
255
+ return await self._call(self.api.add_work_items_to_sprint, sprint_id, _strip_none(payload))
256
+
257
+ async def sprint_remove_work_items(self, payload: Dict[str, Any]) -> Dict[str, Any]:
258
+ return await self._call(self.api.remove_work_items_from_sprint, _strip_none(payload))
259
+
260
+ async def sprint_analytics(self, sprint_id: str) -> Dict[str, Any]:
261
+ return await self._call(self.api.get_sprint_analytics, sprint_id) or {}
262
+
263
+ async def sprint_capacity(self, sprint_id: str) -> Dict[str, Any]:
264
+ return await self._call(self.api.get_sprint_capacity, sprint_id) or {}
265
+
266
+ async def sprint_start(self, sprint_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
267
+ return await self._call(self.api.start_sprint, sprint_id, _strip_none(payload))
268
+
269
+ async def sprint_complete(self, sprint_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
270
+ return await self._call(self.api.complete_sprint, sprint_id, _strip_none(payload))
271
+
272
+ async def sprint_cancel(self, sprint_id: str) -> Dict[str, Any]:
273
+ return await self._call(self.api.cancel_sprint, sprint_id)
274
+
275
+ # ------------------------------------------------------------------
276
+ # Modes and rules
277
+ # ------------------------------------------------------------------
278
+ async def mode_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
279
+ return await self._call(self.api.create_mode, _strip_none(payload))
280
+
281
+ async def mode_list(
282
+ self,
283
+ *,
284
+ include_rules: Optional[bool] = None,
285
+ return_description: Optional[bool] = None,
286
+ return_metadata: Optional[bool] = None,
287
+ ) -> List[Dict[str, Any]]:
288
+ return await self._call(
289
+ self.api.list_modes,
290
+ include_rules=include_rules,
291
+ return_description=return_description,
292
+ return_metadata=return_metadata,
293
+ ) or []
294
+
295
+ async def mode_get(
296
+ self,
297
+ mode_id: str,
298
+ *,
299
+ return_description: Optional[bool] = None,
300
+ return_metadata: Optional[bool] = None,
301
+ ) -> Dict[str, Any]:
302
+ return await self._call(
303
+ self.api.get_mode,
304
+ mode_id,
305
+ return_description=return_description,
306
+ return_metadata=return_metadata,
307
+ )
308
+
309
+ async def mode_update(self, mode_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
310
+ return await self._call(self.api.update_mode, mode_id, _strip_none(payload))
311
+
312
+ async def mode_delete(self, mode_id: str) -> None:
313
+ await self._call(self.api.delete_mode, mode_id)
314
+
315
+ async def mode_rule_add(self, mode_id: str, rule_id: str) -> Dict[str, Any]:
316
+ return await self._call(self.api.add_mode_rule, mode_id, rule_id)
317
+
318
+ async def mode_rule_remove(self, mode_id: str, rule_id: str) -> None:
319
+ await self._call(self.api.remove_mode_rule, mode_id, rule_id)
320
+
321
+ async def mode_rules(self, mode_id: str) -> List[Dict[str, Any]]:
322
+ return await self._call(self.api.list_rules_by_mode, mode_id) or []
323
+
324
+ async def mode_rules_for_rule(self, rule_id: str) -> List[Dict[str, Any]]:
325
+ return await self._call(self.api.list_modes_by_rule, rule_id) or []
326
+
327
+ async def rule_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
328
+ return await self._call(self.api.create_rule, _strip_none(payload))
329
+
330
+ async def rule_list(
331
+ self,
332
+ *,
333
+ return_description: Optional[bool] = None,
334
+ return_metadata: Optional[bool] = None,
335
+ return_modes: Optional[bool] = None,
336
+ ) -> List[Dict[str, Any]]:
337
+ return await self._call(
338
+ self.api.list_rules,
339
+ return_description=return_description,
340
+ return_metadata=return_metadata,
341
+ return_modes=return_modes,
342
+ ) or []
343
+
344
+ async def rule_get(
345
+ self,
346
+ rule_id: str,
347
+ *,
348
+ return_description: Optional[bool] = None,
349
+ return_metadata: Optional[bool] = None,
350
+ return_modes: Optional[bool] = None,
351
+ ) -> Dict[str, Any]:
352
+ return await self._call(
353
+ self.api.get_rule,
354
+ rule_id,
355
+ return_description=return_description,
356
+ return_metadata=return_metadata,
357
+ return_modes=return_modes,
358
+ )
359
+
360
+ async def rule_update(self, rule_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
361
+ return await self._call(self.api.update_rule, rule_id, _strip_none(payload))
362
+
363
+ async def rule_delete(self, rule_id: str) -> None:
364
+ await self._call(self.api.delete_rule, rule_id)
365
+
366
+ # ------------------------------------------------------------------
367
+ # Documentation
368
+ # ------------------------------------------------------------------
369
+ async def doc_create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
370
+ return await self._call_dict(self.api.create_documentation_item, _strip_none(payload))
371
+
372
+ async def doc_list(self, **filters: Any) -> List[Dict[str, Any]]:
373
+ result = await self._call(self.api.list_documentation_items, **_strip_none(filters))
374
+ return _ensure_list(result)
375
+
376
+ async def doc_get(self, doc_id: str, **filters: Any) -> Dict[str, Any]:
377
+ result = await self._call(self.api.get_documentation_item, doc_id, **_strip_none(filters))
378
+ return _ensure_dict(result)
379
+
380
+ async def doc_update(self, doc_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
381
+ return await self._call_dict(self.api.update_documentation_item, doc_id, _strip_none(payload))
382
+
383
+ async def doc_delete(self, doc_id: str) -> None:
384
+ await self._call(self.api.delete_documentation_item, doc_id)
385
+
386
+ async def doc_search(self, *, query: str, team_id: str, limit: int) -> List[Dict[str, Any]]:
387
+ result = await self._call(
388
+ self.api.search_documentation_items,
389
+ query=query,
390
+ team_id=team_id,
391
+ limit=limit,
392
+ )
393
+ return _ensure_list(result)
394
+
395
+ async def doc_roots(self, *, team_id: str) -> List[Dict[str, Any]]:
396
+ result = await self._call(self.api.list_documentation_roots, team_id=team_id)
397
+ return _ensure_list(result)
398
+
399
+ async def doc_recent(self, *, team_id: str, limit: int) -> List[Dict[str, Any]]:
400
+ result = await self._call(
401
+ self.api.list_documentation_recent,
402
+ team_id=team_id,
403
+ limit=limit,
404
+ )
405
+ return _ensure_list(result)
406
+
407
+ async def doc_analytics(self, *, team_id: str) -> Dict[str, Any]:
408
+ result = await self._call(self.api.get_documentation_analytics, team_id=team_id)
409
+ return _ensure_dict(result)
410
+
411
+ async def doc_children(self, doc_id: str) -> List[Dict[str, Any]]:
412
+ return await self._call(self.api.get_documentation_children, doc_id) or []
413
+
414
+ async def doc_tree(self, doc_id: str) -> Dict[str, Any]:
415
+ return await self._call(self.api.get_documentation_tree, doc_id) or {}
416
+
417
+ async def doc_full_tree(self) -> Dict[str, Any]:
418
+ return await self._call(self.api.get_documentation_full_tree) or {}
419
+
420
+ async def doc_move(self, doc_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
421
+ return await self._call_dict(self.api.move_documentation_item, doc_id, _strip_none(payload))
422
+
423
+ async def doc_publish(self, doc_id: str) -> Dict[str, Any]:
424
+ return await self._call_dict(self.api.publish_documentation_item, doc_id)
425
+
426
+ async def doc_version(self, doc_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
427
+ return await self._call_dict(self.api.create_documentation_version, doc_id, _strip_none(payload))
428
+
429
+ async def doc_duplicate(self, doc_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
430
+ return await self._call_dict(self.api.duplicate_documentation_item, doc_id, _strip_none(payload))
431
+
432
+
433
+ __all__ = [
434
+ "KnowledgeService",
435
+ "_strip_none",
436
+ "_format_date",
437
+ ]
@@ -0,0 +1,184 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Domain helpers for productivity (TODO) operations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Any, Dict, Iterable, List, Optional
10
+
11
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient, FenixApiError
12
+
13
+
14
+ def _ensure_iso_datetime(value: str) -> str:
15
+ """Convert common datetime formats to ISO 8601."""
16
+
17
+ value = value.strip()
18
+ if not value:
19
+ raise ValueError("Data vazia.")
20
+ try:
21
+ if len(value) == 10:
22
+ dt = datetime.strptime(value, "%Y-%m-%d")
23
+ return dt.isoformat()
24
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
25
+ return dt.isoformat()
26
+ except ValueError as exc: # pragma: no cover
27
+ raise ValueError(
28
+ "Formato de data inválido. Use YYYY-MM-DD ou ISO completo (ex: 2024-12-31T23:59:59Z)."
29
+ ) from exc
30
+
31
+
32
+ class ProductivityService:
33
+ """Async facade around the Fênix API for TODO management."""
34
+
35
+ def __init__(self, api_client: FenixApiClient, logger):
36
+ self._api = api_client
37
+ self._logger = logger
38
+
39
+ async def create_todo(
40
+ self,
41
+ *,
42
+ title: str,
43
+ content: str,
44
+ status: str,
45
+ priority: str,
46
+ category: Optional[str],
47
+ tags: Iterable[str],
48
+ due_date: str,
49
+ ) -> Dict[str, Any]:
50
+ payload = {
51
+ "title": title,
52
+ "content": content,
53
+ "status": status,
54
+ "priority": priority,
55
+ "category": category,
56
+ "tags": list(tags),
57
+ "dueDate": _ensure_iso_datetime(due_date),
58
+ }
59
+ return await self._call(self._api.create_todo_item, _strip_none(payload))
60
+
61
+ async def list_todos(
62
+ self,
63
+ *,
64
+ limit: int,
65
+ offset: int,
66
+ status: Optional[str] = None,
67
+ priority: Optional[str] = None,
68
+ category: Optional[str] = None,
69
+ ) -> List[Dict[str, Any]]:
70
+ params = {
71
+ "limit": limit,
72
+ "offset": offset,
73
+ "status": status,
74
+ "priority": priority,
75
+ "category": category,
76
+ }
77
+ result = await self._call(self._api.list_todo_items, **_strip_none(params))
78
+ return self._coerce_list(result, keys=("todo_items", "items", "todos"))
79
+
80
+ async def get_todo(self, todo_id: str) -> Dict[str, Any]:
81
+ return await self._call(self._api.get_todo_item, todo_id)
82
+
83
+ async def update_todo(self, todo_id: str, **fields) -> Dict[str, Any]:
84
+ if "due_date" in fields and fields["due_date"]:
85
+ fields["dueDate"] = _ensure_iso_datetime(fields.pop("due_date"))
86
+ payload = _strip_none(fields)
87
+ if not payload:
88
+ raise ValueError("Nenhum campo foi informado para atualização.")
89
+ return await self._call(self._api.update_todo_item, todo_id, payload)
90
+
91
+ async def delete_todo(self, todo_id: str) -> None:
92
+ await self._call(self._api.delete_todo_item, todo_id)
93
+
94
+ async def stats(self) -> Dict[str, Any]:
95
+ return await self._call(self._api.get_todo_stats) or {}
96
+
97
+ async def search(self, query: str, *, limit: int, offset: int) -> List[Dict[str, Any]]:
98
+ # API atual não expõe paginação no endpoint de busca, mas mantemos
99
+ # assinatura para possível suporte futuro.
100
+ result = await self._call(self._api.search_todo_items, query=query)
101
+ return self._coerce_list(result, keys=("todo_items", "items", "todos"))
102
+
103
+ async def overdue(self) -> List[Dict[str, Any]]:
104
+ result = await self._call(self._api.list_todo_overdue)
105
+ return self._coerce_list(result, keys=("todo_items", "items", "todos"))
106
+
107
+ async def upcoming(self, *, days: Optional[int] = None) -> List[Dict[str, Any]]:
108
+ result = await self._call(self._api.list_todo_upcoming, days=days)
109
+ return self._coerce_list(result, keys=("todo_items", "items", "todos"))
110
+
111
+ async def categories(self) -> List[str]:
112
+ payload = await self._call(self._api.get_todo_categories)
113
+ return _coerce_str_list(payload, fallback_key="categories")
114
+
115
+ async def tags(self) -> List[str]:
116
+ payload = await self._call(self._api.get_todo_tags)
117
+ return _coerce_str_list(payload, fallback_key="tags")
118
+
119
+ async def _call(self, func, *args, **kwargs):
120
+ try:
121
+ return await asyncio.to_thread(func, *args, **kwargs)
122
+ except FenixApiError:
123
+ raise
124
+ except Exception as exc: # pragma: no cover
125
+ if self._logger:
126
+ self._logger.error("Erro acessando a API do Fênix: %s", exc)
127
+ raise
128
+
129
+ @staticmethod
130
+ def _coerce_list(value: Any, *, keys: Iterable[str]) -> List[Dict[str, Any]]:
131
+ if isinstance(value, list):
132
+ return value
133
+ if isinstance(value, dict):
134
+ for key in keys:
135
+ data = value.get(key)
136
+ if isinstance(data, list):
137
+ return data
138
+ data = value.get("data")
139
+ if isinstance(data, list):
140
+ return data
141
+ # alguns endpoints retornam dict com 'data' contendo outro dict
142
+ if isinstance(data, dict):
143
+ for key in keys:
144
+ nested = data.get(key)
145
+ if isinstance(nested, list):
146
+ return nested
147
+ return []
148
+
149
+ @staticmethod
150
+ def format_todo(item: Dict[str, Any]) -> str:
151
+ return "\n".join(
152
+ [
153
+ f"📋 **{item.get('title', 'Sem título')}**",
154
+ f"ID: {item.get('id', 'N/A')}",
155
+ f"Status: {item.get('status', 'desconhecido')}",
156
+ f"Prioridade: {item.get('priority', 'desconhecida')}",
157
+ f"Categoria: {item.get('category') or 'não definida'}",
158
+ f"Vencimento: {format_date(item.get('dueDate') or item.get('due_date'))}",
159
+ ]
160
+ )
161
+
162
+
163
+ def _strip_none(data: Dict[str, Any]) -> Dict[str, Any]:
164
+ return {key: value for key, value in data.items() if value not in (None, "")}
165
+
166
+
167
+ def _coerce_str_list(value: Any, *, fallback_key: str) -> List[str]:
168
+ if isinstance(value, list):
169
+ return [str(item) for item in value]
170
+ if isinstance(value, dict):
171
+ data = value.get(fallback_key) or value.get("data")
172
+ if isinstance(data, list):
173
+ return [str(item) for item in data]
174
+ return []
175
+
176
+
177
+ def format_date(value: Optional[str]) -> str:
178
+ if not value:
179
+ return "não definido"
180
+ try:
181
+ dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
182
+ return dt.strftime("%d/%m/%Y")
183
+ except ValueError:
184
+ return value
@@ -0,0 +1,42 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Domain helpers for user configuration documents."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from fenix_mcp.infrastructure.fenix_api.client import FenixApiClient
10
+
11
+
12
+ def _strip_none(data: Dict[str, Any]) -> Dict[str, Any]:
13
+ return {key: value for key, value in data.items() if value not in (None, "")}
14
+
15
+
16
+ class UserConfigService:
17
+ def __init__(self, api: FenixApiClient):
18
+ self._api = api
19
+
20
+ async def create(self, data: Dict[str, Any]) -> Dict[str, Any]:
21
+ payload = _strip_none(data)
22
+ return await asyncio.to_thread(self._api.create_user_core_document, payload)
23
+
24
+ async def list(self, *, returnContent: Optional[bool] = None, **_: Any) -> List[Dict[str, Any]]:
25
+ return await asyncio.to_thread(
26
+ self._api.list_user_core_documents,
27
+ return_content=bool(returnContent),
28
+ ) or []
29
+
30
+ async def get(self, doc_id: str, *, returnContent: Optional[bool] = None, **_: Any) -> Dict[str, Any]:
31
+ return await asyncio.to_thread(
32
+ self._api.get_user_core_document,
33
+ doc_id,
34
+ return_content=bool(returnContent),
35
+ )
36
+
37
+ async def update(self, doc_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
38
+ payload = _strip_none(data)
39
+ return await asyncio.to_thread(self._api.update_user_core_document, doc_id, payload)
40
+
41
+ async def delete(self, doc_id: str) -> None:
42
+ await asyncio.to_thread(self._api.delete_user_core_document, doc_id)