kamau1 commited on
Commit
495529d
·
1 Parent(s): 53b964c

feat: ticket comments

Browse files
docs/api/ticket-comments/API-REFERENCE.md ADDED
File without changes
docs/api/ticket-comments/FRONTEND.md ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Comments - Frontend Quick Reference
2
+
3
+ ## 🎯 6 Endpoints You Need
4
+
5
+ ### 1. Create Comment
6
+ ```typescript
7
+ POST /api/v1/tickets/{ticketId}/comments
8
+
9
+ // Send this:
10
+ {
11
+ comment_text: string, // Required, 1-5000 chars
12
+ is_internal: boolean, // true = team only, false = client sees
13
+ comment_type: string, // "note" | "issue" | "resolution" | "question" | "update"
14
+ parent_comment_id?: string, // For replies, null for top-level
15
+ mentioned_user_ids?: string[],
16
+ attachment_document_ids?: string[]
17
+ }
18
+
19
+ // Get this:
20
+ {
21
+ id: string,
22
+ ticket_id: string,
23
+ user_id: string,
24
+ user_name: string,
25
+ comment_text: string,
26
+ is_internal: boolean,
27
+ comment_type: string,
28
+ parent_comment_id: string | null,
29
+ mentioned_user_ids: string[],
30
+ attachment_document_ids: string[],
31
+ is_edited: boolean,
32
+ edited_at: string | null,
33
+ edited_by_user_id: string | null,
34
+ edited_by_user_name: string | null,
35
+ created_at: string,
36
+ updated_at: string,
37
+ reply_count: number
38
+ }
39
+ ```
40
+
41
+ ---
42
+
43
+ ### 2. List Comments
44
+ ```typescript
45
+ GET /api/v1/tickets/{ticketId}/comments?page=1&page_size=50&is_internal=true&parent_only=false
46
+
47
+ // Get this:
48
+ {
49
+ comments: Comment[], // Array of comments (see structure above)
50
+ total: number, // Total count
51
+ page: number, // Current page
52
+ page_size: number, // Items per page
53
+ pages: number // Total pages
54
+ }
55
+ ```
56
+
57
+ **Query Params:**
58
+ - `page` (default: 1)
59
+ - `page_size` (default: 50, max: 100)
60
+ - `is_internal` (optional: true/false)
61
+ - `comment_type` (optional: "note" | "issue" | "resolution" | "question" | "update")
62
+ - `parent_only` (default: false) - Set true to get only top-level comments
63
+
64
+ ---
65
+
66
+ ### 3. Get Replies
67
+ ```typescript
68
+ GET /api/v1/comments/{commentId}/replies
69
+
70
+ // Get this:
71
+ Comment[] // Array of replies, sorted oldest first
72
+ ```
73
+
74
+ ---
75
+
76
+ ### 4. Update Comment
77
+ ```typescript
78
+ PUT /api/v1/comments/{commentId}
79
+
80
+ // Send this:
81
+ {
82
+ comment_text: string // Required, 1-5000 chars
83
+ }
84
+
85
+ // Get this:
86
+ Comment // Updated comment with is_edited=true
87
+ ```
88
+
89
+ **Auth:** Only comment author can update
90
+
91
+ ---
92
+
93
+ ### 5. Delete Comment
94
+ ```typescript
95
+ DELETE /api/v1/comments/{commentId}
96
+
97
+ // Get this:
98
+ {
99
+ success: true,
100
+ message: "Comment deleted successfully",
101
+ comment_id: string
102
+ }
103
+ ```
104
+
105
+ **Auth:** Comment author OR Project Manager
106
+
107
+ ---
108
+
109
+ ### 6. Get Single Comment
110
+ ```typescript
111
+ GET /api/v1/comments/{commentId}
112
+
113
+ // Get this:
114
+ Comment // Single comment object
115
+ ```
116
+
117
+ ---
118
+
119
+ ## 🎨 UI Patterns
120
+
121
+ ### Pattern 1: Simple List (Mobile)
122
+ ```typescript
123
+ // Load all comments, newest first
124
+ const { comments } = await api.get(`/tickets/${ticketId}/comments`, {
125
+ params: { page_size: 50 }
126
+ });
127
+
128
+ // Display flat list
129
+ comments.forEach(comment => {
130
+ renderComment(comment);
131
+ });
132
+ ```
133
+
134
+ ---
135
+
136
+ ### Pattern 2: Threaded View (Web)
137
+ ```typescript
138
+ // Step 1: Load top-level comments
139
+ const { comments: topLevel } = await api.get(`/tickets/${ticketId}/comments`, {
140
+ params: { parent_only: true }
141
+ });
142
+
143
+ // Step 2: Load replies for comments with reply_count > 0
144
+ for (const comment of topLevel) {
145
+ if (comment.reply_count > 0) {
146
+ const replies = await api.get(`/comments/${comment.id}/replies`);
147
+ comment.replies = replies;
148
+ }
149
+ }
150
+
151
+ // Step 3: Render nested
152
+ renderThreadedComments(topLevel);
153
+ ```
154
+
155
+ ---
156
+
157
+ ### Pattern 3: Create Reply
158
+ ```typescript
159
+ // Reply to a comment
160
+ await api.post(`/tickets/${ticketId}/comments`, {
161
+ comment_text: "I'll handle this",
162
+ parent_comment_id: parentCommentId, // Link to parent
163
+ is_internal: true,
164
+ comment_type: "note"
165
+ });
166
+ ```
167
+
168
+ ---
169
+
170
+ ### Pattern 4: Edit Comment
171
+ ```typescript
172
+ // Update comment text
173
+ await api.put(`/comments/${commentId}`, {
174
+ comment_text: "Updated text"
175
+ });
176
+
177
+ // UI shows "edited" badge
178
+ ```
179
+
180
+ ---
181
+
182
+ ### Pattern 5: Pagination
183
+ ```typescript
184
+ const [page, setPage] = useState(1);
185
+
186
+ const loadComments = async () => {
187
+ const data = await api.get(`/tickets/${ticketId}/comments`, {
188
+ params: { page, page_size: 20 }
189
+ });
190
+
191
+ setComments(data.comments);
192
+ setTotalPages(data.pages);
193
+ };
194
+
195
+ // Load more button
196
+ <button onClick={() => setPage(page + 1)}>Load More</button>
197
+ ```
198
+
199
+ ---
200
+
201
+ ## 🚨 Error Handling
202
+
203
+ ```typescript
204
+ try {
205
+ await api.post(`/tickets/${ticketId}/comments`, data);
206
+ } catch (error) {
207
+ if (error.status === 404) {
208
+ // Ticket not found
209
+ } else if (error.status === 403) {
210
+ // Not authorized (e.g., editing someone else's comment)
211
+ } else if (error.status === 400) {
212
+ // Validation error (e.g., mentioned user doesn't exist)
213
+ }
214
+ }
215
+ ```
216
+
217
+ ---
218
+
219
+ ## 💡 Quick Tips
220
+
221
+ ### For Mobile (Field Agents)
222
+ ```typescript
223
+ // Simple flat list, internal only
224
+ const { comments } = await api.get(`/tickets/${ticketId}/comments`, {
225
+ params: {
226
+ is_internal: true, // Hide external comments
227
+ page_size: 20
228
+ }
229
+ });
230
+ ```
231
+
232
+ ### For Web (Dispatchers/PMs)
233
+ ```typescript
234
+ // Threaded view with all comments
235
+ const { comments } = await api.get(`/tickets/${ticketId}/comments`, {
236
+ params: {
237
+ parent_only: true, // Get top-level first
238
+ page_size: 50
239
+ }
240
+ });
241
+ ```
242
+
243
+ ### Show "Edited" Badge
244
+ ```typescript
245
+ {comment.is_edited && (
246
+ <span>Edited {formatDate(comment.edited_at)}</span>
247
+ )}
248
+ ```
249
+
250
+ ### Show Reply Count
251
+ ```typescript
252
+ {comment.reply_count > 0 && (
253
+ <button onClick={() => loadReplies(comment.id)}>
254
+ {comment.reply_count} replies
255
+ </button>
256
+ )}
257
+ ```
258
+
259
+ ### Mention Users
260
+ ```typescript
261
+ // Extract @mentions from text
262
+ const mentions = comment.comment_text.match(/@\w+/g);
263
+
264
+ // Highlight mentions in UI
265
+ const highlightedText = comment.comment_text.replace(
266
+ /@(\w+)/g,
267
+ '<span class="mention">@$1</span>'
268
+ );
269
+ ```
270
+
271
+ ---
272
+
273
+ ## 📱 Component Examples
274
+
275
+ ### React: Comment List
276
+ ```tsx
277
+ function CommentList({ ticketId }) {
278
+ const [comments, setComments] = useState([]);
279
+
280
+ useEffect(() => {
281
+ api.get(`/tickets/${ticketId}/comments`)
282
+ .then(data => setComments(data.comments));
283
+ }, [ticketId]);
284
+
285
+ return (
286
+ <div>
287
+ {comments.map(comment => (
288
+ <CommentItem key={comment.id} comment={comment} />
289
+ ))}
290
+ </div>
291
+ );
292
+ }
293
+ ```
294
+
295
+ ### React: Create Comment
296
+ ```tsx
297
+ function CommentForm({ ticketId, parentId = null }) {
298
+ const [text, setText] = useState('');
299
+
300
+ const handleSubmit = async () => {
301
+ await api.post(`/tickets/${ticketId}/comments`, {
302
+ comment_text: text,
303
+ parent_comment_id: parentId,
304
+ is_internal: true,
305
+ comment_type: 'note'
306
+ });
307
+ setText('');
308
+ };
309
+
310
+ return (
311
+ <form onSubmit={handleSubmit}>
312
+ <textarea value={text} onChange={e => setText(e.target.value)} />
313
+ <button type="submit">Post</button>
314
+ </form>
315
+ );
316
+ }
317
+ ```
318
+
319
+ ### React: Threaded Comments
320
+ ```tsx
321
+ function ThreadedComment({ comment }) {
322
+ const [replies, setReplies] = useState([]);
323
+ const [showReplies, setShowReplies] = useState(false);
324
+
325
+ const loadReplies = async () => {
326
+ const data = await api.get(`/comments/${comment.id}/replies`);
327
+ setReplies(data);
328
+ setShowReplies(true);
329
+ };
330
+
331
+ return (
332
+ <div className="comment">
333
+ <div className="comment-content">
334
+ <strong>{comment.user_name}</strong>
335
+ <p>{comment.comment_text}</p>
336
+ {comment.is_edited && <span className="edited">Edited</span>}
337
+ </div>
338
+
339
+ {comment.reply_count > 0 && (
340
+ <button onClick={loadReplies}>
341
+ {showReplies ? 'Hide' : 'Show'} {comment.reply_count} replies
342
+ </button>
343
+ )}
344
+
345
+ {showReplies && (
346
+ <div className="replies">
347
+ {replies.map(reply => (
348
+ <ThreadedComment key={reply.id} comment={reply} />
349
+ ))}
350
+ </div>
351
+ )}
352
+ </div>
353
+ );
354
+ }
355
+ ```
356
+
357
+ ---
358
+
359
+ ## 🎯 Common Use Cases
360
+
361
+ ### 1. Field Agent Adds Note
362
+ ```typescript
363
+ await api.post(`/tickets/${ticketId}/comments`, {
364
+ comment_text: "Customer not home, will retry tomorrow",
365
+ is_internal: true,
366
+ comment_type: "update"
367
+ });
368
+ ```
369
+
370
+ ### 2. Dispatcher Asks Question
371
+ ```typescript
372
+ await api.post(`/tickets/${ticketId}/comments`, {
373
+ comment_text: "Can you verify the customer location?",
374
+ is_internal: true,
375
+ comment_type: "question"
376
+ });
377
+ ```
378
+
379
+ ### 3. Agent Replies
380
+ ```typescript
381
+ await api.post(`/tickets/${ticketId}/comments`, {
382
+ comment_text: "Location verified, proceeding",
383
+ parent_comment_id: questionCommentId,
384
+ is_internal: true,
385
+ comment_type: "resolution"
386
+ });
387
+ ```
388
+
389
+ ### 4. PM Deletes Inappropriate Comment
390
+ ```typescript
391
+ await api.delete(`/comments/${commentId}`);
392
+ ```
393
+
394
+ ---
395
+
396
+ ## ⚡ Performance Tips
397
+
398
+ 1. **Lazy load replies** - Don't load all replies upfront
399
+ 2. **Paginate** - Use page_size=20 for mobile, 50 for web
400
+ 3. **Cache** - Cache comments list, invalidate on create/update/delete
401
+ 4. **Optimistic updates** - Show comment immediately, sync in background
402
+ 5. **Debounce** - Debounce edit requests by 500ms
403
+
404
+ ---
405
+
406
+ ## 🔑 Key Fields
407
+
408
+ | Field | Type | Description |
409
+ |-------|------|-------------|
410
+ | `comment_text` | string | The actual comment (1-5000 chars) |
411
+ | `is_internal` | boolean | true = team only, false = client sees |
412
+ | `comment_type` | string | note/issue/resolution/question/update |
413
+ | `parent_comment_id` | string? | null = top-level, uuid = reply |
414
+ | `reply_count` | number | How many replies this comment has |
415
+ | `is_edited` | boolean | Was this comment edited? |
416
+ | `edited_at` | string? | When was it edited? |
417
+
418
+ ---
419
+
420
+ ## ❌ Common Mistakes
421
+
422
+ 1. ❌ Forgetting `is_internal: true` for team comments
423
+ 2. ❌ Not filtering `parent_only: true` for threaded view
424
+ 3. ❌ Loading all replies upfront (performance issue)
425
+ 4. ❌ Not handling 403 errors (editing others' comments)
426
+ 5. ❌ Not showing "edited" badge when `is_edited: true`
427
+
428
+ ---
429
+
430
+ ## ✅ Checklist
431
+
432
+ - [ ] Can create top-level comment
433
+ - [ ] Can create reply (with parent_comment_id)
434
+ - [ ] Can list comments with pagination
435
+ - [ ] Can load replies separately
436
+ - [ ] Can edit own comment
437
+ - [ ] Can delete own comment
438
+ - [ ] Show "edited" badge when is_edited=true
439
+ - [ ] Show reply count
440
+ - [ ] Handle 403/404 errors
441
+ - [ ] Filter internal/external based on user role
docs/api/ticket-comments/IMPLEMENTATION.md ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Comments - Implementation Summary
2
+
3
+ ## ✅ What Was Implemented
4
+
5
+ ### 1. Service Layer (`ticket_comment_service.py`)
6
+ - ✅ `create_comment()` - Create comments with validation
7
+ - ✅ `update_comment()` - Edit comments (author only)
8
+ - ✅ `delete_comment()` - Soft delete (author or PM)
9
+ - ✅ `get_comment()` - Get single comment
10
+ - ✅ `list_comments()` - Paginated list with filtering
11
+ - ✅ `get_comment_replies()` - Load threaded replies
12
+ - ✅ `_to_response()` - Convert model to response schema
13
+
14
+ ### 2. API Endpoints (`ticket_comments.py`)
15
+ - ✅ `POST /tickets/{ticket_id}/comments` - Create comment
16
+ - ✅ `PUT /comments/{comment_id}` - Update comment
17
+ - ✅ `DELETE /comments/{comment_id}` - Delete comment
18
+ - ✅ `GET /comments/{comment_id}` - Get comment
19
+ - ✅ `GET /tickets/{ticket_id}/comments` - List comments
20
+ - ✅ `GET /comments/{comment_id}/replies` - Get replies
21
+
22
+ ### 3. Features
23
+ - ✅ **Threading** - Reply to comments via `parent_comment_id`
24
+ - ✅ **Mentions** - Tag users via `mentioned_user_ids`
25
+ - ✅ **Attachments** - Link documents via `attachment_document_ids`
26
+ - ✅ **Edit Tracking** - Track who edited and when
27
+ - ✅ **Soft Delete** - Preserve data integrity
28
+ - ✅ **Pagination** - Handle large comment lists
29
+ - ✅ **Filtering** - By type, internal/external, parent-only
30
+ - ✅ **Authorization** - Role-based access control
31
+
32
+ ### 4. Validation
33
+ - ✅ Ticket exists before creating comment
34
+ - ✅ Parent comment exists for replies
35
+ - ✅ Mentioned users exist
36
+ - ✅ Only author can edit their comments
37
+ - ✅ Author or PM can delete comments
38
+ - ✅ Comment text not empty
39
+
40
+ ### 5. Documentation
41
+ - ✅ API documentation with examples
42
+ - ✅ Testing guide with curl commands
43
+ - ✅ Error handling documentation
44
+ - ✅ Best practices guide
45
+
46
+ ---
47
+
48
+ ## 🔧 How It Works
49
+
50
+ ### Threading (Replies)
51
+
52
+ ```
53
+ Comment A (parent_comment_id: null)
54
+ ├── Reply B (parent_comment_id: A)
55
+ │ └── Reply C (parent_comment_id: B)
56
+ └── Reply D (parent_comment_id: A)
57
+ ```
58
+
59
+ **Implementation:**
60
+ - Each comment has optional `parent_comment_id`
61
+ - `reply_count` computed in `_to_response()`
62
+ - Replies loaded separately via `/comments/{id}/replies`
63
+ - Replies sorted by `created_at ASC` (oldest first)
64
+
65
+ ### Edit Tracking
66
+
67
+ **When comment is edited:**
68
+ 1. `comment_text` updated
69
+ 2. `is_edited` set to `true`
70
+ 3. `edited_at` set to current timestamp
71
+ 4. `edited_by_user_id` set to editor's ID
72
+ 5. `updated_at` updated
73
+
74
+ **Original text NOT preserved** (consider version history if needed)
75
+
76
+ ### Soft Delete
77
+
78
+ **When comment is deleted:**
79
+ 1. `deleted_at` set to current timestamp
80
+ 2. Comment excluded from all queries via `deleted_at IS NULL`
81
+ 3. Data preserved for audit trail
82
+ 4. Can be restored by setting `deleted_at = NULL`
83
+
84
+ ---
85
+
86
+ ## 🎯 Usage Examples
87
+
88
+ ### Field Agent: Add Note
89
+ ```python
90
+ POST /tickets/{ticket_id}/comments
91
+ {
92
+ "comment_text": "Customer not home, will retry tomorrow",
93
+ "is_internal": true,
94
+ "comment_type": "update"
95
+ }
96
+ ```
97
+
98
+ ### Dispatcher: Ask Question
99
+ ```python
100
+ POST /tickets/{ticket_id}/comments
101
+ {
102
+ "comment_text": "@john Can you check the customer location?",
103
+ "mentioned_user_ids": ["john-uuid"],
104
+ "is_internal": true,
105
+ "comment_type": "question"
106
+ }
107
+ ```
108
+
109
+ ### PM: Reply to Question
110
+ ```python
111
+ POST /tickets/{ticket_id}/comments
112
+ {
113
+ "comment_text": "Location verified, proceed with installation",
114
+ "parent_comment_id": "question-uuid",
115
+ "is_internal": true,
116
+ "comment_type": "resolution"
117
+ }
118
+ ```
119
+
120
+ ### Load Threaded Conversation
121
+ ```python
122
+ # 1. Get top-level comments
123
+ GET /tickets/{ticket_id}/comments?parent_only=true
124
+
125
+ # 2. For each comment with reply_count > 0:
126
+ GET /comments/{comment_id}/replies
127
+ ```
128
+
129
+ ---
130
+
131
+ ## 🔒 Security
132
+
133
+ ### Authorization Matrix
134
+
135
+ | Action | Field Agent | Dispatcher | PM | Platform Admin |
136
+ |--------|-------------|------------|----|----|
137
+ | Create comment | ✅ | ✅ | ✅ | ✅ |
138
+ | View comments | ✅ | ✅ | ✅ | ✅ |
139
+ | Edit own comment | ✅ | ✅ | ✅ | ✅ |
140
+ | Edit others' comment | ❌ | ❌ | ❌ | ❌ |
141
+ | Delete own comment | ✅ | ✅ | ✅ | ✅ |
142
+ | Delete others' comment | ❌ | ❌ | ✅ | ✅ |
143
+
144
+ ### Validation Checks
145
+ 1. ✅ Ticket exists
146
+ 2. ✅ Parent comment exists (for replies)
147
+ 3. ✅ Mentioned users exist
148
+ 4. ✅ User owns comment (for edit)
149
+ 5. ✅ User is author or PM (for delete)
150
+ 6. ✅ Comment text not empty
151
+
152
+ ---
153
+
154
+ ## 📊 Database Impact
155
+
156
+ ### Indexes (Already in schema.sql)
157
+ ```sql
158
+ CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id, deleted_at);
159
+ CREATE INDEX idx_ticket_comments_parent ON ticket_comments(parent_comment_id);
160
+ CREATE INDEX idx_ticket_comments_user ON ticket_comments(user_id);
161
+ CREATE INDEX idx_ticket_comments_created ON ticket_comments(created_at DESC);
162
+ ```
163
+
164
+ ### Storage Estimate
165
+ - Average comment: ~200 bytes
166
+ - 1000 comments: ~200 KB
167
+ - 1 million comments: ~200 MB
168
+
169
+ ---
170
+
171
+ ## 🚀 Performance
172
+
173
+ ### Query Optimization
174
+ - ✅ Use `joinedload()` for relationships
175
+ - ✅ Filter `deleted_at IS NULL` in all queries
176
+ - ✅ Paginate large result sets
177
+ - ✅ Index on `ticket_id`, `parent_comment_id`, `user_id`
178
+
179
+ ### Expected Response Times
180
+ - Create comment: < 100ms
181
+ - List comments (50 items): < 200ms
182
+ - Get replies: < 100ms
183
+ - Update comment: < 100ms
184
+ - Delete comment: < 100ms
185
+
186
+ ---
187
+
188
+ ## 🔮 Future Enhancements
189
+
190
+ ### Phase 2: Notifications
191
+ - [ ] Notify mentioned users
192
+ - [ ] Notify ticket assignees on external comments
193
+ - [ ] Real-time updates via SSE
194
+
195
+ ### Phase 3: Rich Features
196
+ - [ ] Markdown support
197
+ - [ ] Code blocks
198
+ - [ ] File attachments (not just links)
199
+ - [ ] Emoji reactions
200
+
201
+ ### Phase 4: Advanced
202
+ - [ ] Full-text search
203
+ - [ ] Comment version history
204
+ - [ ] Bulk operations
205
+ - [ ] Comment templates
206
+
207
+ ---
208
+
209
+ ## 🐛 Known Limitations
210
+
211
+ 1. **No version history** - Edits overwrite original text
212
+ 2. **No notifications** - Mentions don't trigger notifications yet
213
+ 3. **No rich text** - Plain text only
214
+ 4. **No reactions** - Can't like/emoji comments
215
+ 5. **No search** - Must paginate to find comments
216
+
217
+ ---
218
+
219
+ ## 📝 Testing Checklist
220
+
221
+ - [x] Create comment on valid ticket
222
+ - [x] Create reply to comment
223
+ - [x] Update own comment
224
+ - [x] Delete own comment
225
+ - [x] List comments with pagination
226
+ - [x] Get comment replies
227
+ - [x] Filter by is_internal
228
+ - [x] Filter by comment_type
229
+ - [x] Filter parent_only
230
+ - [x] Validate ticket exists
231
+ - [x] Validate parent comment exists
232
+ - [x] Validate mentioned users exist
233
+ - [x] Prevent editing others' comments
234
+ - [x] Allow PM to delete any comment
235
+ - [x] Soft delete preserves data
236
+ - [x] Reply count accurate
237
+ - [x] Edit tracking works
238
+
239
+ ---
240
+
241
+ ## 🎓 Best Practices
242
+
243
+ ### For Developers
244
+ 1. Always filter `deleted_at IS NULL`
245
+ 2. Use `joinedload()` for relationships
246
+ 3. Validate foreign keys before creating
247
+ 4. Use transactions for data consistency
248
+ 5. Log all operations for debugging
249
+
250
+ ### For Users
251
+ 1. Keep comments concise and actionable
252
+ 2. Use appropriate comment types
253
+ 3. Mark sensitive info as internal
254
+ 4. Reply to comments for context
255
+ 5. Edit instead of delete when possible
256
+
257
+ ---
258
+
259
+ ## 📞 Support
260
+
261
+ ### Common Issues
262
+
263
+ **Q: Comments not appearing?**
264
+ A: Check `deleted_at IS NULL` filter
265
+
266
+ **Q: Can't edit comment?**
267
+ A: Only author can edit their own comments
268
+
269
+ **Q: Reply count wrong?**
270
+ A: Ensure subquery counts `deleted_at IS NULL`
271
+
272
+ **Q: Mentions not working?**
273
+ A: Notifications not implemented yet (Phase 2)
274
+
275
+ ### Contact
276
+ - Backend issues: Check logs in `src/app/services/ticket_comment_service.py`
277
+ - API issues: Check `src/app/api/v1/ticket_comments.py`
278
+ - Database issues: Check indexes and constraints
279
+
280
+ ---
281
+
282
+ ## 👨‍💻 Frontend Team Guide
283
+
284
+ **See [FRONTEND.md](./FRONTEND.md) for:**
285
+ - 6 endpoints with exact request/response formats
286
+ - UI patterns (simple list, threaded view, pagination)
287
+ - React component examples
288
+ - Common use cases
289
+ - Error handling
290
+ - Performance tips
291
+ - Quick reference (no fluff)
docs/api/ticket-comments/QUICK-REF.md ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Comments - API Quick Reference
2
+
3
+ ## Endpoints
4
+
5
+ ```
6
+ POST /api/v1/tickets/{ticketId}/comments Create comment
7
+ GET /api/v1/tickets/{ticketId}/comments List comments
8
+ GET /api/v1/comments/{commentId} Get comment
9
+ GET /api/v1/comments/{commentId}/replies Get replies
10
+ PUT /api/v1/comments/{commentId} Update comment
11
+ DELETE /api/v1/comments/{commentId} Delete comment
12
+ ```
13
+
14
+ ## Request/Response
15
+
16
+ ### Create
17
+ ```json
18
+ // POST /tickets/{id}/comments
19
+ {
20
+ "comment_text": "string",
21
+ "is_internal": true,
22
+ "comment_type": "note",
23
+ "parent_comment_id": null
24
+ }
25
+ → Returns Comment object
26
+ ```
27
+
28
+ ### List
29
+ ```json
30
+ // GET /tickets/{id}/comments?page=1&page_size=50
31
+ → {
32
+ "comments": Comment[],
33
+ "total": 100,
34
+ "page": 1,
35
+ "pages": 2
36
+ }
37
+ ```
38
+
39
+ ### Comment Object
40
+ ```json
41
+ {
42
+ "id": "uuid",
43
+ "comment_text": "string",
44
+ "user_name": "string",
45
+ "is_internal": true,
46
+ "comment_type": "note",
47
+ "parent_comment_id": null,
48
+ "reply_count": 0,
49
+ "is_edited": false,
50
+ "created_at": "2025-11-30T12:00:00Z"
51
+ }
52
+ ```
53
+
54
+ ## Comment Types
55
+ `note` | `issue` | `resolution` | `question` | `update`
56
+
57
+ ## Auth
58
+ - Create: All users
59
+ - Update: Author only
60
+ - Delete: Author or PM
docs/api/ticket-comments/README.md ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Comments API
2
+
3
+ Team collaboration system for tickets with threading, mentions, and attachments.
4
+
5
+ ## Overview
6
+
7
+ The ticket comments system enables team members to collaborate on tickets through:
8
+ - **Internal comments** (team only) and **external comments** (client-visible)
9
+ - **Threading** (replies to comments)
10
+ - **Mentions** (tag team members)
11
+ - **Attachments** (link documents)
12
+ - **Edit tracking** (audit trail)
13
+
14
+ ---
15
+
16
+ ## API Endpoints
17
+
18
+ ### 1. Create Comment
19
+ ```http
20
+ POST /api/v1/tickets/{ticket_id}/comments
21
+ ```
22
+
23
+ **Request Body:**
24
+ ```json
25
+ {
26
+ "comment_text": "Customer prefers morning visits between 9-11am",
27
+ "is_internal": true,
28
+ "comment_type": "note",
29
+ "parent_comment_id": null,
30
+ "mentioned_user_ids": [],
31
+ "attachment_document_ids": []
32
+ }
33
+ ```
34
+
35
+ **Response:** `201 Created`
36
+ ```json
37
+ {
38
+ "id": "uuid",
39
+ "ticket_id": "uuid",
40
+ "user_id": "uuid",
41
+ "user_name": "John Doe",
42
+ "comment_text": "Customer prefers morning visits between 9-11am",
43
+ "is_internal": true,
44
+ "comment_type": "note",
45
+ "parent_comment_id": null,
46
+ "mentioned_user_ids": [],
47
+ "attachment_document_ids": [],
48
+ "is_edited": false,
49
+ "edited_at": null,
50
+ "edited_by_user_id": null,
51
+ "edited_by_user_name": null,
52
+ "additional_metadata": {},
53
+ "created_at": "2025-11-30T12:00:00Z",
54
+ "updated_at": "2025-11-30T12:00:00Z",
55
+ "reply_count": 0
56
+ }
57
+ ```
58
+
59
+ ---
60
+
61
+ ### 2. Update Comment
62
+ ```http
63
+ PUT /api/v1/comments/{comment_id}
64
+ ```
65
+
66
+ **Authorization:** Comment author only
67
+
68
+ **Request Body:**
69
+ ```json
70
+ {
71
+ "comment_text": "Updated: Customer now prefers afternoon visits"
72
+ }
73
+ ```
74
+
75
+ **Response:** `200 OK`
76
+ ```json
77
+ {
78
+ "id": "uuid",
79
+ "comment_text": "Updated: Customer now prefers afternoon visits",
80
+ "is_edited": true,
81
+ "edited_at": "2025-11-30T12:05:00Z",
82
+ "edited_by_user_id": "uuid",
83
+ "edited_by_user_name": "John Doe",
84
+ ...
85
+ }
86
+ ```
87
+
88
+ ---
89
+
90
+ ### 3. Delete Comment
91
+ ```http
92
+ DELETE /api/v1/comments/{comment_id}
93
+ ```
94
+
95
+ **Authorization:** Comment author or Project Manager
96
+
97
+ **Response:** `200 OK`
98
+ ```json
99
+ {
100
+ "success": true,
101
+ "message": "Comment deleted successfully",
102
+ "comment_id": "uuid"
103
+ }
104
+ ```
105
+
106
+ ---
107
+
108
+ ### 4. List Comments
109
+ ```http
110
+ GET /api/v1/tickets/{ticket_id}/comments?page=1&page_size=50&is_internal=true&parent_only=false
111
+ ```
112
+
113
+ **Query Parameters:**
114
+ - `page` (int): Page number (default: 1)
115
+ - `page_size` (int): Items per page (default: 50, max: 100)
116
+ - `is_internal` (bool): Filter by internal/external
117
+ - `comment_type` (string): Filter by type (note, issue, resolution, question, update)
118
+ - `parent_only` (bool): Show only top-level comments (default: false)
119
+
120
+ **Response:** `200 OK`
121
+ ```json
122
+ {
123
+ "comments": [...],
124
+ "total": 25,
125
+ "page": 1,
126
+ "page_size": 50,
127
+ "pages": 1
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ### 5. Get Comment Replies
134
+ ```http
135
+ GET /api/v1/comments/{comment_id}/replies
136
+ ```
137
+
138
+ **Response:** `200 OK`
139
+ ```json
140
+ [
141
+ {
142
+ "id": "uuid",
143
+ "parent_comment_id": "parent-uuid",
144
+ "comment_text": "I'll handle this",
145
+ "user_name": "Jane Smith",
146
+ ...
147
+ }
148
+ ]
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Comment Types
154
+
155
+ | Type | Description | Use Case |
156
+ |------|-------------|----------|
157
+ | `note` | General note | "Customer prefers morning visits" |
158
+ | `issue` | Problem/blocker | "Customer location is incorrect" |
159
+ | `resolution` | Solution/fix | "Updated customer address" |
160
+ | `question` | Question for team | "Should we reschedule?" |
161
+ | `update` | Status update | "Work 50% complete" |
162
+
163
+ ---
164
+
165
+ ## Threading (Replies)
166
+
167
+ ### Create a Reply
168
+ ```json
169
+ {
170
+ "comment_text": "I'll handle the rescheduling",
171
+ "parent_comment_id": "parent-comment-uuid",
172
+ "is_internal": true,
173
+ "comment_type": "note"
174
+ }
175
+ ```
176
+
177
+ ### Load Thread
178
+ 1. **List top-level comments:** `GET /tickets/{id}/comments?parent_only=true`
179
+ 2. **Load replies:** `GET /comments/{comment_id}/replies`
180
+
181
+ ---
182
+
183
+ ## Mentions
184
+
185
+ ### Tag Team Members
186
+ ```json
187
+ {
188
+ "comment_text": "@john Can you check the customer location?",
189
+ "mentioned_user_ids": ["john-uuid"],
190
+ "is_internal": true,
191
+ "comment_type": "question"
192
+ }
193
+ ```
194
+
195
+ **Future:** Mentioned users will receive notifications
196
+
197
+ ---
198
+
199
+ ## Attachments
200
+
201
+ ### Link Documents
202
+ ```json
203
+ {
204
+ "comment_text": "See attached site survey photos",
205
+ "attachment_document_ids": ["doc-uuid-1", "doc-uuid-2"],
206
+ "is_internal": true,
207
+ "comment_type": "note"
208
+ }
209
+ ```
210
+
211
+ ---
212
+
213
+ ## Internal vs External Comments
214
+
215
+ ### Internal Comments (Team Only)
216
+ ```json
217
+ {
218
+ "comment_text": "Customer is difficult, be patient",
219
+ "is_internal": true
220
+ }
221
+ ```
222
+ - Visible to: Team members only
223
+ - Use for: Internal notes, issues, coordination
224
+
225
+ ### External Comments (Client-Visible)
226
+ ```json
227
+ {
228
+ "comment_text": "Installation scheduled for tomorrow 10am",
229
+ "is_internal": false
230
+ }
231
+ ```
232
+ - Visible to: Team + Client
233
+ - Use for: Customer updates, status changes
234
+
235
+ ---
236
+
237
+ ## Edit Tracking
238
+
239
+ When a comment is edited:
240
+ - `is_edited` → `true`
241
+ - `edited_at` → timestamp
242
+ - `edited_by_user_id` → editor's ID
243
+ - `edited_by_user_name` → editor's name
244
+
245
+ **Original text is NOT preserved** (consider adding version history if needed)
246
+
247
+ ---
248
+
249
+ ## Authorization
250
+
251
+ | Action | Who Can Do It |
252
+ |--------|---------------|
253
+ | Create comment | All authenticated users |
254
+ | Update comment | Comment author only |
255
+ | Delete comment | Comment author OR Project Manager |
256
+ | View comments | All authenticated users |
257
+
258
+ ---
259
+
260
+ ## Error Handling
261
+
262
+ ### 404 Not Found
263
+ ```json
264
+ {
265
+ "detail": "Ticket not found"
266
+ }
267
+ ```
268
+
269
+ ### 403 Forbidden
270
+ ```json
271
+ {
272
+ "detail": "You can only edit your own comments"
273
+ }
274
+ ```
275
+
276
+ ### 400 Bad Request
277
+ ```json
278
+ {
279
+ "detail": "One or more mentioned users not found"
280
+ }
281
+ ```
282
+
283
+ ---
284
+
285
+ ## Best Practices
286
+
287
+ ### For Field Agents
288
+ - Use `note` type for general observations
289
+ - Use `issue` type for problems encountered
290
+ - Keep comments concise and actionable
291
+ - Always mark as `is_internal: true`
292
+
293
+ ### For Dispatchers
294
+ - Use `question` type when asking agents
295
+ - Use `update` type for status changes
296
+ - Tag relevant team members with mentions
297
+ - Use external comments for customer updates
298
+
299
+ ### For Project Managers
300
+ - Review external comments before posting
301
+ - Use `resolution` type for solutions
302
+ - Delete inappropriate comments if needed
303
+ - Monitor comment activity for team collaboration
304
+
305
+ ---
306
+
307
+ ## Frontend Integration
308
+
309
+ ### Mobile App (Field Agents)
310
+ ```typescript
311
+ // List comments
312
+ const comments = await api.get(`/tickets/${ticketId}/comments`, {
313
+ params: { is_internal: true, page_size: 20 }
314
+ });
315
+
316
+ // Create comment
317
+ await api.post(`/tickets/${ticketId}/comments`, {
318
+ comment_text: "Customer not home, will retry tomorrow",
319
+ is_internal: true,
320
+ comment_type: "update"
321
+ });
322
+
323
+ // Reply to comment
324
+ await api.post(`/tickets/${ticketId}/comments`, {
325
+ comment_text: "Okay, I'll reschedule",
326
+ parent_comment_id: parentCommentId,
327
+ is_internal: true,
328
+ comment_type: "note"
329
+ });
330
+ ```
331
+
332
+ ### Web Dashboard (Dispatchers/PMs)
333
+ ```typescript
334
+ // Load threaded comments
335
+ const topLevel = await api.get(`/tickets/${ticketId}/comments`, {
336
+ params: { parent_only: true }
337
+ });
338
+
339
+ // Load replies for each comment
340
+ for (const comment of topLevel.comments) {
341
+ if (comment.reply_count > 0) {
342
+ const replies = await api.get(`/comments/${comment.id}/replies`);
343
+ comment.replies = replies;
344
+ }
345
+ }
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Database Schema
351
+
352
+ ```sql
353
+ CREATE TABLE ticket_comments (
354
+ id UUID PRIMARY KEY,
355
+ ticket_id UUID NOT NULL REFERENCES tickets(id),
356
+ user_id UUID REFERENCES users(id),
357
+ comment_text TEXT NOT NULL,
358
+ is_internal BOOLEAN DEFAULT TRUE,
359
+ comment_type VARCHAR(50) DEFAULT 'note',
360
+ parent_comment_id UUID REFERENCES ticket_comments(id),
361
+ mentioned_user_ids UUID[],
362
+ attachment_document_ids UUID[],
363
+ is_edited BOOLEAN DEFAULT FALSE,
364
+ edited_at TIMESTAMP WITH TIME ZONE,
365
+ edited_by_user_id UUID REFERENCES users(id),
366
+ additional_metadata JSONB DEFAULT '{}',
367
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
368
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
369
+ deleted_at TIMESTAMP WITH TIME ZONE
370
+ );
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Future Enhancements
376
+
377
+ 1. **Notifications**
378
+ - Notify mentioned users
379
+ - Notify ticket assignees on external comments
380
+ - Real-time updates via SSE
381
+
382
+ 2. **Rich Text**
383
+ - Markdown support
384
+ - Code blocks
385
+ - Formatting
386
+
387
+ 3. **Reactions**
388
+ - Like/emoji reactions
389
+ - Quick acknowledgments
390
+
391
+ 4. **Version History**
392
+ - Track all edits
393
+ - View previous versions
394
+ - Restore old versions
395
+
396
+ 5. **Search**
397
+ - Full-text search across comments
398
+ - Filter by author
399
+ - Date range filtering
docs/api/ticket-comments/TESTING.md ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Ticket Comments - Testing Guide
2
+
3
+ ## Manual Testing Checklist
4
+
5
+ ### 1. Create Comment ✅
6
+ ```bash
7
+ curl -X POST "http://localhost:7860/api/v1/tickets/{ticket_id}/comments" \
8
+ -H "Authorization: Bearer {token}" \
9
+ -H "Content-Type: application/json" \
10
+ -d '{
11
+ "comment_text": "Customer prefers morning visits",
12
+ "is_internal": true,
13
+ "comment_type": "note"
14
+ }'
15
+ ```
16
+
17
+ **Expected:** 201 Created with comment details
18
+
19
+ ---
20
+
21
+ ### 2. Create Reply (Threading) ✅
22
+ ```bash
23
+ curl -X POST "http://localhost:7860/api/v1/tickets/{ticket_id}/comments" \
24
+ -H "Authorization: Bearer {token}" \
25
+ -H "Content-Type: application/json" \
26
+ -d '{
27
+ "comment_text": "I will handle this",
28
+ "parent_comment_id": "{parent_comment_id}",
29
+ "is_internal": true,
30
+ "comment_type": "note"
31
+ }'
32
+ ```
33
+
34
+ **Expected:** 201 Created with parent_comment_id set
35
+
36
+ ---
37
+
38
+ ### 3. List Comments ✅
39
+ ```bash
40
+ curl -X GET "http://localhost:7860/api/v1/tickets/{ticket_id}/comments?page=1&page_size=20" \
41
+ -H "Authorization: Bearer {token}"
42
+ ```
43
+
44
+ **Expected:** 200 OK with paginated list
45
+
46
+ ---
47
+
48
+ ### 4. Get Comment Replies ✅
49
+ ```bash
50
+ curl -X GET "http://localhost:7860/api/v1/comments/{comment_id}/replies" \
51
+ -H "Authorization: Bearer {token}"
52
+ ```
53
+
54
+ **Expected:** 200 OK with array of replies
55
+
56
+ ---
57
+
58
+ ### 5. Update Comment ✅
59
+ ```bash
60
+ curl -X PUT "http://localhost:7860/api/v1/comments/{comment_id}" \
61
+ -H "Authorization: Bearer {token}" \
62
+ -H "Content-Type: application/json" \
63
+ -d '{
64
+ "comment_text": "Updated: Customer prefers afternoon"
65
+ }'
66
+ ```
67
+
68
+ **Expected:** 200 OK with is_edited=true
69
+
70
+ ---
71
+
72
+ ### 6. Delete Comment ✅
73
+ ```bash
74
+ curl -X DELETE "http://localhost:7860/api/v1/comments/{comment_id}" \
75
+ -H "Authorization: Bearer {token}"
76
+ ```
77
+
78
+ **Expected:** 200 OK with success message
79
+
80
+ ---
81
+
82
+ ## Error Cases to Test
83
+
84
+ ### 1. Create Comment on Non-Existent Ticket
85
+ ```bash
86
+ curl -X POST "http://localhost:7860/api/v1/tickets/00000000-0000-0000-0000-000000000000/comments" \
87
+ -H "Authorization: Bearer {token}" \
88
+ -H "Content-Type: application/json" \
89
+ -d '{"comment_text": "Test", "is_internal": true, "comment_type": "note"}'
90
+ ```
91
+
92
+ **Expected:** 404 Not Found - "Ticket not found"
93
+
94
+ ---
95
+
96
+ ### 2. Update Someone Else's Comment
97
+ ```bash
98
+ # Create comment as User A
99
+ # Try to update as User B
100
+ curl -X PUT "http://localhost:7860/api/v1/comments/{comment_id}" \
101
+ -H "Authorization: Bearer {user_b_token}" \
102
+ -H "Content-Type: application/json" \
103
+ -d '{"comment_text": "Hacked!"}'
104
+ ```
105
+
106
+ **Expected:** 403 Forbidden - "You can only edit your own comments"
107
+
108
+ ---
109
+
110
+ ### 3. Reply to Non-Existent Comment
111
+ ```bash
112
+ curl -X POST "http://localhost:7860/api/v1/tickets/{ticket_id}/comments" \
113
+ -H "Authorization: Bearer {token}" \
114
+ -H "Content-Type: application/json" \
115
+ -d '{
116
+ "comment_text": "Reply",
117
+ "parent_comment_id": "00000000-0000-0000-0000-000000000000",
118
+ "is_internal": true,
119
+ "comment_type": "note"
120
+ }'
121
+ ```
122
+
123
+ **Expected:** 404 Not Found - "Parent comment not found"
124
+
125
+ ---
126
+
127
+ ### 4. Mention Non-Existent User
128
+ ```bash
129
+ curl -X POST "http://localhost:7860/api/v1/tickets/{ticket_id}/comments" \
130
+ -H "Authorization: Bearer {token}" \
131
+ -H "Content-Type: application/json" \
132
+ -d '{
133
+ "comment_text": "Test",
134
+ "mentioned_user_ids": ["00000000-0000-0000-0000-000000000000"],
135
+ "is_internal": true,
136
+ "comment_type": "note"
137
+ }'
138
+ ```
139
+
140
+ **Expected:** 400 Bad Request - "One or more mentioned users not found"
141
+
142
+ ---
143
+
144
+ ### 5. Empty Comment Text
145
+ ```bash
146
+ curl -X POST "http://localhost:7860/api/v1/tickets/{ticket_id}/comments" \
147
+ -H "Authorization: Bearer {token}" \
148
+ -H "Content-Type: application/json" \
149
+ -d '{
150
+ "comment_text": "",
151
+ "is_internal": true,
152
+ "comment_type": "note"
153
+ }'
154
+ ```
155
+
156
+ **Expected:** 422 Validation Error - "comment_text must be at least 1 character"
157
+
158
+ ---
159
+
160
+ ## Integration Testing
161
+
162
+ ### Scenario 1: Threaded Conversation
163
+ 1. User A creates comment: "Need help with this ticket"
164
+ 2. User B replies: "I can help"
165
+ 3. User A replies to B: "Thanks!"
166
+ 4. Verify reply_count increments
167
+ 5. Load replies and verify order (oldest first)
168
+
169
+ ### Scenario 2: Edit Tracking
170
+ 1. User creates comment
171
+ 2. User edits comment
172
+ 3. Verify is_edited=true, edited_at set, edited_by_user_id set
173
+ 4. Edit again
174
+ 5. Verify edited_at updated
175
+
176
+ ### Scenario 3: Soft Delete
177
+ 1. User creates comment
178
+ 2. User deletes comment
179
+ 3. Verify deleted_at set
180
+ 4. Try to list comments - deleted comment should not appear
181
+ 5. Try to get deleted comment by ID - should return 404
182
+
183
+ ### Scenario 4: Pagination
184
+ 1. Create 100 comments
185
+ 2. List with page_size=20
186
+ 3. Verify pages=5
187
+ 4. Load each page
188
+ 5. Verify no duplicates, no missing comments
189
+
190
+ ---
191
+
192
+ ## Performance Testing
193
+
194
+ ### Load Test: Create 1000 Comments
195
+ ```python
196
+ import asyncio
197
+ import aiohttp
198
+
199
+ async def create_comment(session, ticket_id, token):
200
+ async with session.post(
201
+ f"http://localhost:7860/api/v1/tickets/{ticket_id}/comments",
202
+ headers={"Authorization": f"Bearer {token}"},
203
+ json={
204
+ "comment_text": "Load test comment",
205
+ "is_internal": True,
206
+ "comment_type": "note"
207
+ }
208
+ ) as response:
209
+ return await response.json()
210
+
211
+ async def main():
212
+ async with aiohttp.ClientSession() as session:
213
+ tasks = [create_comment(session, ticket_id, token) for _ in range(1000)]
214
+ results = await asyncio.gather(*tasks)
215
+ print(f"Created {len(results)} comments")
216
+
217
+ asyncio.run(main())
218
+ ```
219
+
220
+ **Expected:** All 1000 comments created successfully in < 30 seconds
221
+
222
+ ---
223
+
224
+ ## Database Verification
225
+
226
+ ### Check Comment Count
227
+ ```sql
228
+ SELECT COUNT(*) FROM ticket_comments WHERE deleted_at IS NULL;
229
+ ```
230
+
231
+ ### Check Threading
232
+ ```sql
233
+ SELECT
234
+ id,
235
+ comment_text,
236
+ parent_comment_id,
237
+ (SELECT COUNT(*) FROM ticket_comments c2 WHERE c2.parent_comment_id = c1.id) as reply_count
238
+ FROM ticket_comments c1
239
+ WHERE ticket_id = '{ticket_id}' AND deleted_at IS NULL
240
+ ORDER BY created_at DESC;
241
+ ```
242
+
243
+ ### Check Edit History
244
+ ```sql
245
+ SELECT
246
+ id,
247
+ comment_text,
248
+ is_edited,
249
+ edited_at,
250
+ edited_by_user_id
251
+ FROM ticket_comments
252
+ WHERE is_edited = TRUE AND deleted_at IS NULL;
253
+ ```
254
+
255
+ ---
256
+
257
+ ## Common Issues & Solutions
258
+
259
+ ### Issue: 500 Internal Server Error
260
+ **Cause:** Missing relationship loading
261
+ **Solution:** Ensure all relationships use `joinedload()` in service layer
262
+
263
+ ### Issue: Comments not appearing
264
+ **Cause:** Soft delete not filtered
265
+ **Solution:** Always filter `deleted_at IS NULL`
266
+
267
+ ### Issue: Reply count incorrect
268
+ **Cause:** Not counting replies properly
269
+ **Solution:** Use subquery to count replies in `_to_response()`
270
+
271
+ ### Issue: Mentions not working
272
+ **Cause:** User IDs not validated
273
+ **Solution:** Query users table to verify all mentioned_user_ids exist
274
+
275
+ ---
276
+
277
+ ## Monitoring
278
+
279
+ ### Key Metrics to Track
280
+ - Comment creation rate (comments/hour)
281
+ - Average reply depth (threading level)
282
+ - Edit frequency (edits/comments ratio)
283
+ - Delete frequency (deletes/comments ratio)
284
+ - Response time for list endpoint
285
+
286
+ ### Alerts to Set Up
287
+ - Comment creation failures > 1%
288
+ - List endpoint response time > 2 seconds
289
+ - Database connection errors
290
+ - Validation errors > 5%
docs/devlogs/db/logs.sql CHANGED
@@ -1 +1 @@
1
- INSERT INTO "public"."tickets" ("id", "project_id", "source", "source_id", "ticket_name", "ticket_type", "service_type", "work_description", "status", "priority", "scheduled_date", "scheduled_time_slot", "due_date", "sla_target_date", "sla_violated", "started_at", "completed_at", "is_invoiced", "invoiced_at", "contractor_invoice_id", "project_region_id", "work_location_latitude", "work_location_longitude", "work_location_accuracy", "work_location_verified", "dedup_key", "notes", "additional_metadata", "version", "created_at", "updated_at", "deleted_at", "required_team_size", "completion_data", "completion_photos_verified", "completion_data_verified") VALUES ('8f08ad14-df8b-4780-84e7-0d45e133f2a6', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '5cb76cc5-3d9a-4ce0-87a8-43f912896c1f', 'Catherine Njoki', 'installation', 'ftth', 'Install Basic 10Mbps for Catherine Njoki', 'in_progress', 'normal', '2025-12-28', 'Afternoon 1PM-4PM', '2025-12-01 07:52:17.604326+00', '2025-12-01 07:52:17.604326+00', 'false', null, null, 'false', null, null, null, null, null, null, 'false', 'b2888f980ff72ea1192b21d6905e0825', null, '{}', '1', '2025-11-28 07:52:17.604326+00', '2025-11-28 10:04:37.59353+00', null, '1', '{}', 'false', 'false'), ('f59b29fc-d0b9-4618-b0d1-889e340da612', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'c93b28c0-d7bf-4f57-b750-d0c6864543b0', 'Elizabeth Muthoni', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Elizabeth Muthoni', 'completed', 'normal', '2025-12-11', null, '2025-11-29 13:24:19.212882+00', '2025-11-29 13:24:19.212882+00', 'true', null, '2025-11-30 12:08:38.793534+00', 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', null, '[COMPLETION] we finished connection', '{}', '1', '2025-11-26 13:24:19.212882+00', '2025-11-30 12:08:38.858902+00', null, '1', '{"odu_serial": "jjhh", "ont_serial": "jhh"}', 'true', 'true');
 
1
+ INSERT INTO "public"."tickets" ("id", "project_id", "source", "source_id", "ticket_name", "ticket_type", "service_type", "work_description", "status", "priority", "scheduled_date", "scheduled_time_slot", "due_date", "sla_target_date", "sla_violated", "started_at", "completed_at", "is_invoiced", "invoiced_at", "contractor_invoice_id", "project_region_id", "work_location_latitude", "work_location_longitude", "work_location_accuracy", "work_location_verified", "dedup_key", "notes", "additional_metadata", "version", "created_at", "updated_at", "deleted_at", "required_team_size", "completion_data", "completion_photos_verified", "completion_data_verified") VALUES ('8f08ad14-df8b-4780-84e7-0d45e133f2a6', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', '5cb76cc5-3d9a-4ce0-87a8-43f912896c1f', 'Catherine Njoki', 'installation', 'ftth', 'Install Basic 10Mbps for Catherine Njoki', 'in_progress', 'normal', '2025-12-28', 'Afternoon 1PM-4PM', '2025-12-01 07:52:17.604326+00', '2025-12-01 07:52:17.604326+00', 'false', null, null, 'false', null, null, null, null, null, null, 'false', 'b2888f980ff72ea1192b21d6905e0825', null, '{}', '1', '2025-11-28 07:52:17.604326+00', '2025-11-28 10:04:37.59353+00', null, '1', '{}', 'false', 'false'), ('f59b29fc-d0b9-4618-b0d1-889e340da612', '0ade6bd1-e492-4e25-b681-59f42058d29a', 'sales_order', 'c93b28c0-d7bf-4f57-b750-d0c6864543b0', 'Elizabeth Muthoni', 'installation', 'ftth', 'Install Premium Fiber 100Mbps for Elizabeth Muthoni', 'completed', 'normal', '2025-12-11', null, '2025-11-29 13:24:19.212882+00', '2025-11-29 13:24:19.212882+00', 'true', null, '2025-11-30 12:08:38.793534+00', 'false', null, null, '4cd27765-5720-4cc0-872e-bf0da3cd1898', null, null, null, 'false', null, '[COMPLETION] we finished connection', '{}', '1', '2025-11-26 13:24:19.212882+00', '2025-11-30 12:08:38.858902+00', null, '1', '{"odu_serial": "jjhh", "ont_serial": "jhh"}', 'true', 'true');
src/app/api/v1/router.py CHANGED
@@ -6,7 +6,7 @@ from app.api.v1 import (
6
  auth, clients, contractors, organizations, invitations, profile, users,
7
  financial_accounts, asset_assignments, documents, otp, projects,
8
  customers, timesheets, payroll, tasks, inventory, finance, sales_orders, tickets,
9
- ticket_assignments, ticket_completion, expenses, incidents, contractor_invoices, notifications, map, export, public_tracking, public_hub_tracking,
10
  audit_logs, analytics, progress_reports, incident_reports
11
  )
12
 
@@ -77,6 +77,9 @@ api_router.include_router(ticket_assignments.router, prefix="/ticket-assignments
77
  # Ticket Completion (Dynamic Checklists + Progressive Completion + Validation)
78
  api_router.include_router(ticket_completion.router, prefix="/tickets", tags=["Ticket Completion"])
79
 
 
 
 
80
  # Ticket Expenses (Expense Tracking + Approval + Payment Routing)
81
  api_router.include_router(expenses.router, prefix="/expenses", tags=["Expenses"])
82
 
 
6
  auth, clients, contractors, organizations, invitations, profile, users,
7
  financial_accounts, asset_assignments, documents, otp, projects,
8
  customers, timesheets, payroll, tasks, inventory, finance, sales_orders, tickets,
9
+ ticket_assignments, ticket_completion, ticket_comments, expenses, incidents, contractor_invoices, notifications, map, export, public_tracking, public_hub_tracking,
10
  audit_logs, analytics, progress_reports, incident_reports
11
  )
12
 
 
77
  # Ticket Completion (Dynamic Checklists + Progressive Completion + Validation)
78
  api_router.include_router(ticket_completion.router, prefix="/tickets", tags=["Ticket Completion"])
79
 
80
+ # Ticket Comments (Team Collaboration + Threading + Mentions)
81
+ api_router.include_router(ticket_comments.router, tags=["Ticket Comments"])
82
+
83
  # Ticket Expenses (Expense Tracking + Approval + Payment Routing)
84
  api_router.include_router(expenses.router, prefix="/expenses", tags=["Expenses"])
85
 
src/app/api/v1/ticket_comments.py ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ticket Comments API - Team Collaboration
3
+
4
+ Endpoints for:
5
+ 1. Create comments on tickets
6
+ 2. Update comments (by author)
7
+ 3. Delete comments (by author or PM)
8
+ 4. List comments with filtering
9
+ 5. Get comment replies (threading)
10
+
11
+ Authorization:
12
+ - All authenticated users can create comments
13
+ - Only author can edit their own comments
14
+ - Author or PM can delete comments
15
+ """
16
+
17
+ from fastapi import APIRouter, Depends, status, Query
18
+ from sqlalchemy.orm import Session
19
+ from typing import Optional, List
20
+ from uuid import UUID
21
+
22
+ from app.api.deps import get_db, get_current_user
23
+ from app.models.user import User
24
+ from app.services.ticket_comment_service import TicketCommentService
25
+ from app.schemas.ticket_comment import (
26
+ TicketCommentCreate,
27
+ TicketCommentUpdate,
28
+ TicketCommentResponse,
29
+ TicketCommentListResponse,
30
+ COMMENT_TYPES
31
+ )
32
+
33
+ router = APIRouter()
34
+
35
+
36
+ # ============================================
37
+ # CREATE COMMENT
38
+ # ============================================
39
+
40
+ @router.post(
41
+ "/tickets/{ticket_id}/comments",
42
+ response_model=TicketCommentResponse,
43
+ status_code=status.HTTP_201_CREATED,
44
+ summary="Create comment on ticket",
45
+ description="""
46
+ Create a new comment on a ticket.
47
+
48
+ **Features:**
49
+ - Internal comments (team only) or external (client-visible)
50
+ - Threading: Reply to other comments using parent_comment_id
51
+ - Mentions: Tag users with mentioned_user_ids
52
+ - Attachments: Link documents with attachment_document_ids
53
+
54
+ **Comment Types:**
55
+ - note: General note or observation
56
+ - issue: Problem or blocker
57
+ - resolution: Solution or fix
58
+ - question: Question for team
59
+ - update: Status update
60
+
61
+ **Authorization:** All authenticated users
62
+ """
63
+ )
64
+ def create_comment(
65
+ ticket_id: UUID,
66
+ data: TicketCommentCreate,
67
+ db: Session = Depends(get_db),
68
+ current_user: User = Depends(get_current_user)
69
+ ):
70
+ """Create a new comment on a ticket"""
71
+ return TicketCommentService.create_comment(
72
+ ticket_id=ticket_id,
73
+ data=data,
74
+ current_user=current_user,
75
+ db=db
76
+ )
77
+
78
+
79
+ # ============================================
80
+ # UPDATE COMMENT
81
+ # ============================================
82
+
83
+ @router.put(
84
+ "/comments/{comment_id}",
85
+ response_model=TicketCommentResponse,
86
+ summary="Update comment",
87
+ description="""
88
+ Update a comment (only by original author).
89
+
90
+ **Edit Tracking:**
91
+ - is_edited flag set to true
92
+ - edited_at timestamp recorded
93
+ - edited_by_user_id tracked
94
+
95
+ **Authorization:** Comment author only
96
+ """
97
+ )
98
+ def update_comment(
99
+ comment_id: UUID,
100
+ data: TicketCommentUpdate,
101
+ db: Session = Depends(get_db),
102
+ current_user: User = Depends(get_current_user)
103
+ ):
104
+ """Update a comment (author only)"""
105
+ return TicketCommentService.update_comment(
106
+ comment_id=comment_id,
107
+ data=data,
108
+ current_user=current_user,
109
+ db=db
110
+ )
111
+
112
+
113
+ # ============================================
114
+ # DELETE COMMENT
115
+ # ============================================
116
+
117
+ @router.delete(
118
+ "/comments/{comment_id}",
119
+ status_code=status.HTTP_200_OK,
120
+ summary="Delete comment",
121
+ description="""
122
+ Delete a comment (soft delete).
123
+
124
+ **Authorization:**
125
+ - Comment author can delete their own comments
126
+ - Project managers can delete any comment
127
+ - Platform admins can delete any comment
128
+ """
129
+ )
130
+ def delete_comment(
131
+ comment_id: UUID,
132
+ db: Session = Depends(get_db),
133
+ current_user: User = Depends(get_current_user)
134
+ ):
135
+ """Delete a comment (soft delete)"""
136
+ return TicketCommentService.delete_comment(
137
+ comment_id=comment_id,
138
+ current_user=current_user,
139
+ db=db
140
+ )
141
+
142
+
143
+ # ============================================
144
+ # GET SINGLE COMMENT
145
+ # ============================================
146
+
147
+ @router.get(
148
+ "/comments/{comment_id}",
149
+ response_model=TicketCommentResponse,
150
+ summary="Get comment by ID",
151
+ description="""
152
+ Get a single comment by ID.
153
+
154
+ **Returns:**
155
+ - Comment details
156
+ - Author information
157
+ - Edit history
158
+ - Reply count
159
+
160
+ **Authorization:** All authenticated users
161
+ """
162
+ )
163
+ def get_comment(
164
+ comment_id: UUID,
165
+ db: Session = Depends(get_db),
166
+ current_user: User = Depends(get_current_user)
167
+ ):
168
+ """Get a single comment"""
169
+ return TicketCommentService.get_comment(
170
+ comment_id=comment_id,
171
+ db=db
172
+ )
173
+
174
+
175
+ # ============================================
176
+ # LIST COMMENTS
177
+ # ============================================
178
+
179
+ @router.get(
180
+ "/tickets/{ticket_id}/comments",
181
+ response_model=TicketCommentListResponse,
182
+ summary="List ticket comments",
183
+ description="""
184
+ List all comments for a ticket with pagination and filtering.
185
+
186
+ **Filtering:**
187
+ - is_internal: Show only internal or external comments
188
+ - comment_type: Filter by comment type (note, issue, resolution, etc.)
189
+ - parent_only: Show only top-level comments (exclude replies)
190
+
191
+ **Pagination:**
192
+ - page: Page number (1-indexed)
193
+ - page_size: Items per page (default 50, max 100)
194
+
195
+ **Sorting:**
196
+ - Comments sorted by created_at DESC (newest first)
197
+
198
+ **Authorization:** All authenticated users
199
+ """
200
+ )
201
+ def list_comments(
202
+ ticket_id: UUID,
203
+ page: int = Query(1, ge=1, description="Page number"),
204
+ page_size: int = Query(50, ge=1, le=100, description="Items per page"),
205
+ is_internal: Optional[bool] = Query(None, description="Filter by internal/external"),
206
+ comment_type: Optional[str] = Query(None, description=f"Filter by type: {', '.join(COMMENT_TYPES)}"),
207
+ parent_only: bool = Query(False, description="Show only top-level comments"),
208
+ db: Session = Depends(get_db),
209
+ current_user: User = Depends(get_current_user)
210
+ ):
211
+ """List comments for a ticket"""
212
+ return TicketCommentService.list_comments(
213
+ ticket_id=ticket_id,
214
+ page=page,
215
+ page_size=page_size,
216
+ is_internal=is_internal,
217
+ comment_type=comment_type,
218
+ parent_only=parent_only,
219
+ db=db
220
+ )
221
+
222
+
223
+ # ============================================
224
+ # GET COMMENT REPLIES
225
+ # ============================================
226
+
227
+ @router.get(
228
+ "/comments/{comment_id}/replies",
229
+ response_model=List[TicketCommentResponse],
230
+ summary="Get comment replies",
231
+ description="""
232
+ Get all replies to a specific comment (threading).
233
+
234
+ **Use Case:**
235
+ - Load replies when user expands a comment thread
236
+ - Show conversation history
237
+
238
+ **Sorting:**
239
+ - Replies sorted by created_at ASC (oldest first)
240
+
241
+ **Authorization:** All authenticated users
242
+ """
243
+ )
244
+ def get_comment_replies(
245
+ comment_id: UUID,
246
+ db: Session = Depends(get_db),
247
+ current_user: User = Depends(get_current_user)
248
+ ):
249
+ """Get all replies to a comment"""
250
+ return TicketCommentService.get_comment_replies(
251
+ comment_id=comment_id,
252
+ db=db
253
+ )
src/app/services/ticket_comment_service.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ticket Comment Service - Team Collaboration on Tickets
3
+
4
+ Handles:
5
+ - Creating comments (with mentions and attachments)
6
+ - Updating comments (with edit tracking)
7
+ - Deleting comments (soft delete)
8
+ - Threading (replies to comments)
9
+ - Listing comments (with pagination and filtering)
10
+ """
11
+
12
+ from sqlalchemy.orm import Session, joinedload
13
+ from fastapi import HTTPException, status
14
+ from typing import List, Optional, Dict, Any
15
+ from uuid import UUID
16
+ from datetime import datetime, timezone
17
+ import logging
18
+
19
+ from app.models.ticket_comment import TicketComment
20
+ from app.models.ticket import Ticket
21
+ from app.models.user import User
22
+ from app.schemas.ticket_comment import (
23
+ TicketCommentCreate,
24
+ TicketCommentUpdate,
25
+ TicketCommentResponse,
26
+ TicketCommentListResponse
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class TicketCommentService:
33
+ """Service for managing ticket comments"""
34
+
35
+ @staticmethod
36
+ def create_comment(
37
+ ticket_id: UUID,
38
+ data: TicketCommentCreate,
39
+ current_user: User,
40
+ db: Session
41
+ ) -> TicketCommentResponse:
42
+ """
43
+ Create a new comment on a ticket
44
+
45
+ Args:
46
+ ticket_id: Ticket to comment on
47
+ data: Comment data
48
+ current_user: User creating comment
49
+ db: Database session
50
+
51
+ Returns:
52
+ Created comment
53
+ """
54
+ # Verify ticket exists
55
+ ticket = db.query(Ticket).filter(
56
+ Ticket.id == ticket_id,
57
+ Ticket.deleted_at.is_(None)
58
+ ).first()
59
+
60
+ if not ticket:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_404_NOT_FOUND,
63
+ detail="Ticket not found"
64
+ )
65
+
66
+ # Verify parent comment exists if specified
67
+ if data.parent_comment_id:
68
+ parent_comment = db.query(TicketComment).filter(
69
+ TicketComment.id == data.parent_comment_id,
70
+ TicketComment.ticket_id == ticket_id,
71
+ TicketComment.deleted_at.is_(None)
72
+ ).first()
73
+
74
+ if not parent_comment:
75
+ raise HTTPException(
76
+ status_code=status.HTTP_404_NOT_FOUND,
77
+ detail="Parent comment not found"
78
+ )
79
+
80
+ # Verify mentioned users exist
81
+ if data.mentioned_user_ids:
82
+ mentioned_users = db.query(User).filter(
83
+ User.id.in_(data.mentioned_user_ids),
84
+ User.deleted_at.is_(None)
85
+ ).all()
86
+
87
+ if len(mentioned_users) != len(data.mentioned_user_ids):
88
+ raise HTTPException(
89
+ status_code=status.HTTP_400_BAD_REQUEST,
90
+ detail="One or more mentioned users not found"
91
+ )
92
+
93
+ # Create comment
94
+ comment = TicketComment(
95
+ ticket_id=ticket_id,
96
+ user_id=current_user.id,
97
+ comment_text=data.comment_text.strip(),
98
+ is_internal=data.is_internal,
99
+ comment_type=data.comment_type,
100
+ parent_comment_id=data.parent_comment_id,
101
+ mentioned_user_ids=data.mentioned_user_ids if data.mentioned_user_ids else None,
102
+ attachment_document_ids=data.attachment_document_ids if data.attachment_document_ids else None,
103
+ created_at=datetime.now(timezone.utc),
104
+ updated_at=datetime.now(timezone.utc)
105
+ )
106
+
107
+ db.add(comment)
108
+ db.commit()
109
+ db.refresh(comment)
110
+
111
+ logger.info(f"Comment {comment.id} created on ticket {ticket_id} by user {current_user.id}")
112
+
113
+ # TODO: Send notifications to mentioned users
114
+ # TODO: Notify ticket assignees if external comment
115
+
116
+ return TicketCommentService._to_response(comment, db)
117
+
118
+ @staticmethod
119
+ def update_comment(
120
+ comment_id: UUID,
121
+ data: TicketCommentUpdate,
122
+ current_user: User,
123
+ db: Session
124
+ ) -> TicketCommentResponse:
125
+ """
126
+ Update a comment (only by original author)
127
+
128
+ Args:
129
+ comment_id: Comment to update
130
+ data: Updated comment data
131
+ current_user: User updating comment
132
+ db: Database session
133
+
134
+ Returns:
135
+ Updated comment
136
+ """
137
+ comment = db.query(TicketComment).filter(
138
+ TicketComment.id == comment_id,
139
+ TicketComment.deleted_at.is_(None)
140
+ ).first()
141
+
142
+ if not comment:
143
+ raise HTTPException(
144
+ status_code=status.HTTP_404_NOT_FOUND,
145
+ detail="Comment not found"
146
+ )
147
+
148
+ # Only author can edit their own comment
149
+ if comment.user_id != current_user.id:
150
+ raise HTTPException(
151
+ status_code=status.HTTP_403_FORBIDDEN,
152
+ detail="You can only edit your own comments"
153
+ )
154
+
155
+ # Update comment
156
+ comment.comment_text = data.comment_text.strip()
157
+ comment.is_edited = True
158
+ comment.edited_at = datetime.now(timezone.utc)
159
+ comment.edited_by_user_id = current_user.id
160
+ comment.updated_at = datetime.now(timezone.utc)
161
+
162
+ db.commit()
163
+ db.refresh(comment)
164
+
165
+ logger.info(f"Comment {comment_id} updated by user {current_user.id}")
166
+
167
+ return TicketCommentService._to_response(comment, db)
168
+
169
+ @staticmethod
170
+ def delete_comment(
171
+ comment_id: UUID,
172
+ current_user: User,
173
+ db: Session
174
+ ) -> Dict[str, Any]:
175
+ """
176
+ Delete a comment (soft delete, only by author or PM)
177
+
178
+ Args:
179
+ comment_id: Comment to delete
180
+ current_user: User deleting comment
181
+ db: Database session
182
+
183
+ Returns:
184
+ Success message
185
+ """
186
+ comment = db.query(TicketComment).filter(
187
+ TicketComment.id == comment_id,
188
+ TicketComment.deleted_at.is_(None)
189
+ ).first()
190
+
191
+ if not comment:
192
+ raise HTTPException(
193
+ status_code=status.HTTP_404_NOT_FOUND,
194
+ detail="Comment not found"
195
+ )
196
+
197
+ # Only author or PM can delete
198
+ is_author = comment.user_id == current_user.id
199
+ is_pm = current_user.role in ['project_manager', 'platform_admin']
200
+
201
+ if not (is_author or is_pm):
202
+ raise HTTPException(
203
+ status_code=status.HTTP_403_FORBIDDEN,
204
+ detail="You can only delete your own comments"
205
+ )
206
+
207
+ # Soft delete
208
+ comment.deleted_at = datetime.now(timezone.utc)
209
+ db.commit()
210
+
211
+ logger.info(f"Comment {comment_id} deleted by user {current_user.id}")
212
+
213
+ return {
214
+ "success": True,
215
+ "message": "Comment deleted successfully",
216
+ "comment_id": str(comment_id)
217
+ }
218
+
219
+ @staticmethod
220
+ def get_comment(
221
+ comment_id: UUID,
222
+ db: Session
223
+ ) -> TicketCommentResponse:
224
+ """
225
+ Get a single comment by ID
226
+
227
+ Args:
228
+ comment_id: Comment ID
229
+ db: Database session
230
+
231
+ Returns:
232
+ Comment details
233
+ """
234
+ comment = db.query(TicketComment).options(
235
+ joinedload(TicketComment.user),
236
+ joinedload(TicketComment.edited_by_user)
237
+ ).filter(
238
+ TicketComment.id == comment_id,
239
+ TicketComment.deleted_at.is_(None)
240
+ ).first()
241
+
242
+ if not comment:
243
+ raise HTTPException(
244
+ status_code=status.HTTP_404_NOT_FOUND,
245
+ detail="Comment not found"
246
+ )
247
+
248
+ return TicketCommentService._to_response(comment, db)
249
+
250
+ @staticmethod
251
+ def list_comments(
252
+ ticket_id: UUID,
253
+ page: int = 1,
254
+ page_size: int = 50,
255
+ is_internal: Optional[bool] = None,
256
+ comment_type: Optional[str] = None,
257
+ parent_only: bool = False,
258
+ db: Session = None
259
+ ) -> TicketCommentListResponse:
260
+ """
261
+ List comments for a ticket with pagination and filtering
262
+
263
+ Args:
264
+ ticket_id: Ticket ID
265
+ page: Page number (1-indexed)
266
+ page_size: Items per page
267
+ is_internal: Filter by internal/external
268
+ comment_type: Filter by comment type
269
+ parent_only: Only show top-level comments (no replies)
270
+ db: Database session
271
+
272
+ Returns:
273
+ Paginated list of comments
274
+ """
275
+ # Verify ticket exists
276
+ ticket = db.query(Ticket).filter(
277
+ Ticket.id == ticket_id,
278
+ Ticket.deleted_at.is_(None)
279
+ ).first()
280
+
281
+ if not ticket:
282
+ raise HTTPException(
283
+ status_code=status.HTTP_404_NOT_FOUND,
284
+ detail="Ticket not found"
285
+ )
286
+
287
+ # Build query
288
+ query = db.query(TicketComment).options(
289
+ joinedload(TicketComment.user),
290
+ joinedload(TicketComment.edited_by_user)
291
+ ).filter(
292
+ TicketComment.ticket_id == ticket_id,
293
+ TicketComment.deleted_at.is_(None)
294
+ )
295
+
296
+ # Apply filters
297
+ if is_internal is not None:
298
+ query = query.filter(TicketComment.is_internal == is_internal)
299
+
300
+ if comment_type:
301
+ query = query.filter(TicketComment.comment_type == comment_type)
302
+
303
+ if parent_only:
304
+ query = query.filter(TicketComment.parent_comment_id.is_(None))
305
+
306
+ # Get total count
307
+ total = query.count()
308
+
309
+ # Apply pagination
310
+ offset = (page - 1) * page_size
311
+ comments = query.order_by(
312
+ TicketComment.created_at.desc()
313
+ ).offset(offset).limit(page_size).all()
314
+
315
+ # Convert to response
316
+ comment_responses = [
317
+ TicketCommentService._to_response(comment, db)
318
+ for comment in comments
319
+ ]
320
+
321
+ pages = (total + page_size - 1) // page_size
322
+
323
+ return TicketCommentListResponse(
324
+ comments=comment_responses,
325
+ total=total,
326
+ page=page,
327
+ page_size=page_size,
328
+ pages=pages
329
+ )
330
+
331
+ @staticmethod
332
+ def get_comment_replies(
333
+ comment_id: UUID,
334
+ db: Session
335
+ ) -> List[TicketCommentResponse]:
336
+ """
337
+ Get all replies to a comment
338
+
339
+ Args:
340
+ comment_id: Parent comment ID
341
+ db: Database session
342
+
343
+ Returns:
344
+ List of reply comments
345
+ """
346
+ # Verify parent comment exists
347
+ parent = db.query(TicketComment).filter(
348
+ TicketComment.id == comment_id,
349
+ TicketComment.deleted_at.is_(None)
350
+ ).first()
351
+
352
+ if not parent:
353
+ raise HTTPException(
354
+ status_code=status.HTTP_404_NOT_FOUND,
355
+ detail="Comment not found"
356
+ )
357
+
358
+ # Get replies
359
+ replies = db.query(TicketComment).options(
360
+ joinedload(TicketComment.user),
361
+ joinedload(TicketComment.edited_by_user)
362
+ ).filter(
363
+ TicketComment.parent_comment_id == comment_id,
364
+ TicketComment.deleted_at.is_(None)
365
+ ).order_by(TicketComment.created_at.asc()).all()
366
+
367
+ return [
368
+ TicketCommentService._to_response(reply, db)
369
+ for reply in replies
370
+ ]
371
+
372
+ @staticmethod
373
+ def _to_response(comment: TicketComment, db: Session) -> TicketCommentResponse:
374
+ """Convert comment model to response schema"""
375
+
376
+ # Count replies
377
+ reply_count = db.query(TicketComment).filter(
378
+ TicketComment.parent_comment_id == comment.id,
379
+ TicketComment.deleted_at.is_(None)
380
+ ).count()
381
+
382
+ return TicketCommentResponse(
383
+ id=comment.id,
384
+ ticket_id=comment.ticket_id,
385
+ user_id=comment.user_id,
386
+ user_name=comment.user.name if comment.user else None,
387
+ comment_text=comment.comment_text,
388
+ is_internal=comment.is_internal,
389
+ comment_type=comment.comment_type,
390
+ parent_comment_id=comment.parent_comment_id,
391
+ mentioned_user_ids=comment.mentioned_user_ids or [],
392
+ attachment_document_ids=comment.attachment_document_ids or [],
393
+ is_edited=comment.is_edited,
394
+ edited_at=comment.edited_at,
395
+ edited_by_user_id=comment.edited_by_user_id,
396
+ edited_by_user_name=comment.edited_by_user.name if comment.edited_by_user else None,
397
+ additional_metadata=comment.additional_metadata or {},
398
+ created_at=comment.created_at,
399
+ updated_at=comment.updated_at,
400
+ reply_count=reply_count
401
+ )