Spaces:
Sleeping
Sleeping
feat: ticket comments
Browse files- docs/api/ticket-comments/API-REFERENCE.md +0 -0
- docs/api/ticket-comments/FRONTEND.md +441 -0
- docs/api/ticket-comments/IMPLEMENTATION.md +291 -0
- docs/api/ticket-comments/QUICK-REF.md +60 -0
- docs/api/ticket-comments/README.md +399 -0
- docs/api/ticket-comments/TESTING.md +290 -0
- docs/devlogs/db/logs.sql +1 -1
- src/app/api/v1/router.py +4 -1
- src/app/api/v1/ticket_comments.py +253 -0
- src/app/services/ticket_comment_service.py +401 -0
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 |
+
)
|