File size: 14,634 Bytes
cccf200
 
 
 
 
 
 
 
 
 
 
 
 
ecfade7
cccf200
 
fa948af
 
 
ecfade7
fa948af
 
 
 
ecfade7
 
 
 
cccf200
ecfade7
 
 
 
 
cccf200
ecfade7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cccf200
 
ecfade7
cccf200
 
fa948af
 
 
 
 
 
 
 
 
cccf200
 
 
 
 
 
 
 
 
 
 
ecfade7
cccf200
 
fa948af
 
 
 
 
 
 
 
cccf200
 
 
 
 
 
ecfade7
cccf200
 
 
fa948af
 
 
 
ecfade7
fa948af
 
 
 
 
ecfade7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa948af
ecfade7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa948af
ecfade7
fa948af
 
ecfade7
fa948af
ecfade7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cccf200
 
ecfade7
cccf200
 
fa948af
 
 
 
 
 
 
 
 
 
cccf200
 
 
 
 
fa948af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cccf200
 
 
 
 
ecfade7
cccf200
 
fa948af
 
 
 
 
 
 
 
cccf200
 
 
 
 
fa948af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ecfade7
fa948af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ecfade7
fa948af
 
 
 
ecfade7
fa948af
ecfade7
 
fa948af
 
 
 
 
 
 
 
 
 
 
ecfade7
 
 
fa948af
 
 
 
 
 
 
 
 
 
ecfade7
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
"""
Task service migrated to use Neon PostgreSQL.
"""
from typing import List, Optional
from datetime import datetime
from sqlmodel import Session, select
from src.models.task import Task, TaskCreate, TaskUpdate


class TaskService:
    """Service for task CRUD operations with PostgreSQL."""

    @staticmethod
    def create_task(session: Session, user_id: str, task_data: TaskCreate) -> Task:
        """
        Create a new task for a user.

        Args:
            session: Database session
            user_id: ID of the user creating the task (must be string for Better Auth)
            task_data: Task creation data

        Returns:
            Task: Created task entity

        Raises:
            ValueError: If user_id is invalid or subitems cannot be serialized
            Exception: Database errors during commit
        """
        try:
            # Ensure user_id is string (for Better Auth compatibility)
            user_id_str = str(user_id) if user_id else None
            if not user_id_str:
                raise ValueError("user_id is required and cannot be empty")

            # Normalize subitems: use either subitems or shopping_list
            subitems_data = task_data.subitems or task_data.shopping_list

            # Validate JSON serialization of subitems if provided
            if subitems_data:
                import json
                try:
                    json.dumps(subitems_data)  # Test serialization
                except (TypeError, ValueError) as e:
                    raise ValueError(f"Subitems must be JSON serializable: {e}")

            task = Task(
                user_id=user_id_str,
                title=task_data.title,
                description=task_data.description,
                client_id=task_data.client_id,
                category=task_data.category,
                tags=task_data.tags,
                status=task_data.status or "pending",
                priority=task_data.priority or "medium",
                shopping_list=task_data.shopping_list,  # Keep for compatibility
                subitems=subitems_data,  # Use normalized subitems
                recursion=task_data.recursion,
                due_date=task_data.due_date
            )

            session.add(task)
            session.commit()
            session.refresh(task)
            return task

        except Exception as e:
            session.rollback()
            import logging
            logger = logging.getLogger(__name__)
            logger.error(f"Error creating task for user {user_id}: {e}", exc_info=True)
            raise

    @staticmethod
    def get_tasks(session: Session, user_id: str, skip: int = 0, limit: int = 100) -> List[Task]:
        """
        Get all tasks for a user with pagination.

        Args:
            session: Database session
            user_id: ID of the user
            skip: Number of records to skip
            limit: Maximum number of records to return

        Returns:
            List[Task]: List of user's tasks
        """
        statement = (
            select(Task)
            .where(Task.user_id == user_id)
            .order_by(Task.created_at.desc())
            .offset(skip)
            .limit(limit)
        )
        return list(session.exec(statement).all())

    @staticmethod
    def get_task_by_id(session: Session, task_id: int, user_id: str) -> Optional[Task]:
        """
        Get a specific task by ID for a user.

        Args:
            session: Database session
            task_id: Task ID
            user_id: ID of the user

        Returns:
            Optional[Task]: Task if found and belongs to user, None otherwise
        """
        statement = select(Task).where(Task.id == task_id, Task.user_id == user_id)
        return session.exec(statement).first()

    @staticmethod
    def update_task(
        session: Session, task_id: int, user_id: str, task_data: TaskUpdate
    ) -> Optional[Task]:
        """
        Update a task.

        Args:
            session: Database session
            task_id: Task ID to update
            user_id: ID of the user (must be string)
            task_data: Updated task data

        Returns:
            Optional[Task]: Updated task or None if not found

        Raises:
            ValueError: If subitems cannot be serialized
        """
        try:
            task = TaskService.get_task_by_id(session, task_id, user_id)
            if not task:
                return None

            # Track if we're completing the task
            was_incomplete = not task.completed
            is_being_completed = task_data.completed is True

            # Update only provided fields
            if task_data.title is not None:
                task.title = task_data.title
            if task_data.description is not None:
                task.description = task_data.description
            if task_data.completed is not None:
                task.completed = task_data.completed
                # Keep status in sync with completed flag
                if task_data.completed:
                    task.status = "completed"
                elif task.status == "completed":
                    task.status = "pending"

            # Handle subitems with validation
            if task_data.subitems is not None:
                import json
                try:
                    json.dumps(task_data.subitems)  # Validate JSON serialization
                    task.subitems = task_data.subitems
                except (TypeError, ValueError) as e:
                    raise ValueError(f"Subitems must be JSON serializable: {e}")

            if task_data.category is not None:
                task.category = task_data.category
            if task_data.tags is not None:
                task.tags = task_data.tags
            if task_data.status is not None:
                task.status = task_data.status
                # Keep completed flag in sync with status
                if task_data.status == "completed":
                    task.completed = True
                elif task_data.status in ("pending", "active"):
                    task.completed = False
            if task_data.priority is not None:
                task.priority = task_data.priority
            if task_data.shopping_list is not None:
                task.shopping_list = task_data.shopping_list
            if task_data.recursion is not None:
                task.recursion = task_data.recursion
            if task_data.due_date is not None:
                task.due_date = task_data.due_date

            task.updated_at = datetime.utcnow()
            task.version += 1

            session.add(task)
            session.commit()
            session.refresh(task)

            # If task is being marked complete, create history entry
            if was_incomplete and is_being_completed:
                # Create history entry for completed task
                from .history_service import HistoryService
                from src.models.task_history import HistoryActionType
                try:
                    HistoryService.create_history_entry(
                        session=session,
                        task=task,
                        action_type=HistoryActionType.COMPLETED,
                        action_by=user_id
                    )
                except Exception as e:
                    # Log but don't fail update if history creation fails
                    import logging
                    logger = logging.getLogger(__name__)
                    logger.error(f"Failed to create history entry for task {task_id}: {e}")

                # If recurring, create next instance
                if task.is_recurring and task.due_date and task.recurrence_pattern:
                    try:
                        TaskService.create_recurring_instance(session, task)
                    except Exception as e:
                        # Log but don't fail update if instance creation fails
                        import logging
                        logger = logging.getLogger(__name__)
                        logger.error(f"Failed to create recurring instance for task {task_id}: {e}")

            return task

        except Exception as e:
            session.rollback()
            import logging
            logger = logging.getLogger(__name__)
            logger.error(f"Error updating task {task_id} for user {user_id}: {e}", exc_info=True)
            raise

    @staticmethod
    def delete_task(session: Session, task_id: int, user_id: str) -> bool:
        """
        Delete a task.

        Creates a history entry before deletion to enable restoration.

        Args:
            session: Database session
            task_id: Task ID to delete
            user_id: ID of the user

        Returns:
            bool: True if deleted, False if not found
        """
        task = TaskService.get_task_by_id(session, task_id, user_id)
        if not task:
            return False

        # Create history entry before deletion
        from .history_service import HistoryService
        from src.models.task_history import HistoryActionType
        try:
            HistoryService.create_history_entry(
                session=session,
                task=task,
                action_type=HistoryActionType.DELETED,
                action_by=user_id
            )
        except Exception as e:
            # Log but don't fail deletion if history creation fails
            import logging
            logger = logging.getLogger(__name__)
            logger.error(f"Failed to create history entry for deleted task {task_id}: {e}")

        session.delete(task)
        session.commit()
        return True

    @staticmethod
    def get_task_by_client_id(session: Session, client_id: str, user_id: str) -> Optional[Task]:
        """
        Get a task by its client-generated ID (for offline sync).

        Args:
            session: Database session
            client_id: Client-generated unique ID
            user_id: ID of the user

        Returns:
            Optional[Task]: Task if found, None otherwise
        """
        statement = select(Task).where(
            Task.client_id == client_id, Task.user_id == user_id
        )
        return session.exec(statement).first()

    @staticmethod
    def create_recurring_instance(session: Session, original_task: Task) -> Task:
        """
        Create next instance of a recurring task.

        This method is called when a recurring task is completed to automatically
        create the next occurrence with the same properties but a new due date.

        Args:
            session: Database session
            original_task: The completed recurring task

        Returns:
            Task: Newly created recurring task instance

        Raises:
            ValueError: If task is not recurring or missing required fields
        """
        if not original_task.is_recurring:
            raise ValueError(f"Task {original_task.id} is not a recurring task")

        if not original_task.due_date or not original_task.recurrence_pattern:
            raise ValueError(
                f"Recurring task {original_task.id} missing due_date or recurrence_pattern"
            )

        # Calculate next occurrence
        next_due = original_task.calculate_next_occurrence()

        # Create new task instance with same properties
        new_task = Task(
            user_id=original_task.user_id,
            title=original_task.title,
            description=original_task.description,
            due_date=next_due,
            recurrence_pattern=original_task.recurrence_pattern,
            is_recurring=True,
            reminder_minutes=original_task.reminder_minutes,
            next_occurrence=None,  # Will be calculated on next completion
            completed=False,
            client_id=None  # Don't copy client_id to avoid duplicates
        )

        session.add(new_task)
        session.commit()
        session.refresh(new_task)

        # Schedule notification for the new instance
        from .scheduler_service import get_scheduler
        try:
            scheduler = get_scheduler()
            scheduler.schedule_notification(
                task_id=new_task.id,
                task_title=new_task.title,
                due_date=new_task.due_date,
                reminder_minutes=new_task.reminder_minutes
            )
        except Exception as e:
            # Log but don't fail task creation if notification scheduling fails
            import logging
            logger = logging.getLogger(__name__)
            logger.warning(f"Failed to schedule notification for recurring task {new_task.id}: {e}")

        return new_task

    @staticmethod
    def complete_task(session: Session, task_id: int, user_id: str) -> Optional[Task]:
        """
        Mark a task as completed.

        Creates a history entry and, for recurring tasks, creates the next instance.

        Args:
            session: Database session
            task_id: Task ID to complete
            user_id: ID of the user

        Returns:
            Optional[Task]: Completed task or None if not found
        """
        task = TaskService.get_task_by_id(session, task_id, user_id)
        if not task:
            return None

        # Mark as completed
        task.completed = True
        task.status = "completed"
        task.updated_at = datetime.utcnow()
        task.version += 1
        session.add(task)

        # Try to create history entry in same transaction
        try:
            from .history_service import HistoryService
            from src.models.task_history import HistoryActionType
            HistoryService.create_history_entry(
                session=session,
                task=task,
                action_type=HistoryActionType.COMPLETED,
                action_by=user_id
            )
        except Exception as e:
            import logging
            logger = logging.getLogger(__name__)
            logger.error(f"Failed to create history entry for task {task_id}: {e}")

        session.commit()
        session.refresh(task)

        # If recurring, create next instance
        if task.is_recurring and task.due_date and task.recurrence_pattern:
            try:
                TaskService.create_recurring_instance(session, task)
            except Exception as e:
                import logging
                logger = logging.getLogger(__name__)
                logger.error(f"Failed to create recurring instance for task {task_id}: {e}")

        return task