@xcelsior/support-api 0.1.9 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcelsior/support-api",
3
- "version": "0.1.9",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "packages/**",
@@ -25,11 +25,11 @@
25
25
  "typescript": "^5.3.3",
26
26
  "@tsconfig/node18": "^18.2.2",
27
27
  "@types/node": "^20.11.16",
28
- "@xcelsior/lambda-http": "1.0.5",
28
+ "@xcelsior/aws": "1.0.5",
29
+ "@xcelsior/support-client": "1.0.6",
29
30
  "@xcelsior/email": "1.0.2",
30
- "@xcelsior/aws": "1.0.4",
31
- "@xcelsior/monitoring": "1.0.4",
32
- "@xcelsior/support-client": "1.0.3"
31
+ "@xcelsior/lambda-http": "1.0.5",
32
+ "@xcelsior/monitoring": "1.0.4"
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "sst dev --stage dev --mode basic",
@@ -46,3 +46,4 @@ const listTicketEvents = async (event: APIGatewayProxyEventV2) => {
46
46
  };
47
47
 
48
48
  export const handler = middyfy(listTicketEvents, middlewareConfig);
49
+
@@ -11,6 +11,7 @@ export type TicketSearchDocument = {
11
11
  categoryId: string | null;
12
12
  teamId: string | null;
13
13
  assigneeUserId: string | null;
14
+ assigneeEmail: string | null;
14
15
  requesterName: string;
15
16
  requesterEmail: string;
16
17
  attachments: string[];
@@ -119,6 +120,7 @@ export const mapTicketToDocument = (ticket: any): TicketSearchDocument => ({
119
120
  categoryId: ticket.categoryId ?? null,
120
121
  teamId: ticket.teamId ?? null,
121
122
  assigneeUserId: ticket.assigneeUserId ?? null,
123
+ assigneeEmail: ticket.assigneeEmail ?? null,
122
124
  requesterName: ticket.requesterName ?? '',
123
125
  requesterEmail: ticket.requesterEmail ?? '',
124
126
  attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [],
@@ -5,12 +5,12 @@ import { createResponse, logger, middlewareConfig } from '../config';
5
5
  import { ticketListQuerySchema } from '../types';
6
6
  import {
7
7
  buildTicketFilters,
8
- decodeOffsetToken,
9
- encodeOffsetToken,
10
8
  getTicketIndex,
11
9
  type TicketSearchDocument,
12
10
  } from '../search/ticketSearch';
13
11
 
12
+ const DEFAULT_PAGE_SIZE = 25;
13
+
14
14
  const listTickets = async (event: APIGatewayProxyEventV2) => {
15
15
  let query;
16
16
  try {
@@ -22,10 +22,18 @@ const listTickets = async (event: APIGatewayProxyEventV2) => {
22
22
  throw error;
23
23
  }
24
24
 
25
- const { status, priority, categoryId, assigneeUserId, teamId, search, limit, nextToken } =
26
- query;
25
+ const {
26
+ status,
27
+ priority,
28
+ categoryId,
29
+ assigneeUserId,
30
+ teamId,
31
+ search,
32
+ page = 1,
33
+ pageSize = DEFAULT_PAGE_SIZE,
34
+ } = query;
27
35
 
28
- const offset = decodeOffsetToken(nextToken);
36
+ const offset = (page - 1) * pageSize;
29
37
  logger.info('List tickets', {
30
38
  status,
31
39
  priority,
@@ -33,7 +41,8 @@ const listTickets = async (event: APIGatewayProxyEventV2) => {
33
41
  assigneeUserId,
34
42
  teamId,
35
43
  search,
36
- limit,
44
+ page,
45
+ pageSize,
37
46
  offset,
38
47
  });
39
48
 
@@ -43,20 +52,20 @@ const listTickets = async (event: APIGatewayProxyEventV2) => {
43
52
  const searchResult = await index.search<TicketSearchDocument>(search ?? '', {
44
53
  filter: filters.length ? filters.join(' AND ') : undefined,
45
54
  sort: ['createdAt:desc'],
46
- limit,
55
+ limit: pageSize,
47
56
  offset,
48
57
  });
49
58
 
59
+ const total = searchResult.estimatedTotalHits ?? 0;
60
+
50
61
  return createResponse({
51
62
  items: (searchResult.hits ?? []).map((hit: TicketSearchDocument) => ({
52
63
  ...hit,
53
64
  attachments: hit.attachments ?? [],
54
65
  })),
55
- nextToken:
56
- typeof searchResult.estimatedTotalHits === 'number' &&
57
- offset + (searchResult.hits?.length ?? 0) < searchResult.estimatedTotalHits
58
- ? encodeOffsetToken(offset + (searchResult.hits?.length ?? 0))
59
- : undefined,
66
+ page,
67
+ pageSize,
68
+ total,
60
69
  });
61
70
  };
62
71
 
@@ -18,297 +18,269 @@ import { SqsService } from '@xcelsior/aws/src/sqs';
18
18
  import { randomUUID } from 'node:crypto';
19
19
 
20
20
  type SlaConfig = { low?: number; medium?: number; high?: number; critical?: number };
21
- const defaultSla: Required<SlaConfig> = { low: 7, medium: 5, high: 3, critical: 1 };
21
+ type TicketUpdatePayload = z.infer<typeof ticketUpdateSchema>;
22
22
 
23
- const computeReminderAt = (priority?: string, sla?: SlaConfig) => {
23
+ const DEFAULT_SLA: Required<SlaConfig> = { low: 7, medium: 5, high: 3, critical: 1 };
24
+
25
+ // Reserved words in DynamoDB that need expression attribute names
26
+ const RESERVED_WORDS = new Set(['status', 'subject']);
27
+
28
+ interface UpdateExpressionBuilder {
29
+ setExpressions: string[];
30
+ removeExpressions: string[];
31
+ values: Record<string, any>;
32
+ names: Record<string, string>;
33
+ }
34
+
35
+ interface ChangeTracker {
36
+ changes: Record<string, { oldValue: any; newValue: any }>;
37
+ }
38
+
39
+ /**
40
+ * Compute the reminder date based on priority and SLA configuration
41
+ */
42
+ const computeReminderAt = (priority?: string | null, sla?: SlaConfig): string | undefined => {
24
43
  if (!priority) return undefined;
25
- const days = (sla?.[priority as keyof SlaConfig] ??
26
- defaultSla[priority as keyof typeof defaultSla]) as number | undefined;
44
+ const days =
45
+ sla?.[priority as keyof SlaConfig] ?? DEFAULT_SLA[priority as keyof typeof DEFAULT_SLA];
27
46
  if (!days) return undefined;
28
47
  const date = new Date();
29
48
  date.setDate(date.getDate() + days);
30
49
  return date.toISOString();
31
50
  };
32
51
 
33
- const updateTicket = async (event: APIGatewayProxyEventV2) => {
34
- const id = event.pathParameters?.id;
35
- if (!id) {
36
- throw new ValidationError('Ticket id is required', []);
37
- }
52
+ /**
53
+ * Add a field to the update expression and track changes
54
+ */
55
+ const addFieldUpdate = (
56
+ field: string,
57
+ newValue: any,
58
+ oldValue: any,
59
+ builder: UpdateExpressionBuilder,
60
+ tracker: ChangeTracker,
61
+ options: { nullableOldValue?: boolean; arrayDefault?: boolean } = {}
62
+ ) => {
63
+ const isReserved = RESERVED_WORDS.has(field);
64
+ const attrName = isReserved ? `#${field}` : field;
65
+ const attrValue = `:${field}`;
66
+
67
+ builder.setExpressions.push(`${attrName} = ${attrValue}`);
68
+ builder.values[attrValue] = newValue;
69
+
70
+ if (isReserved) {
71
+ builder.names[attrName] = field;
72
+ }
73
+
74
+ // Track the change
75
+ const normalizedOldValue = options.arrayDefault
76
+ ? (oldValue ?? [])
77
+ : options.nullableOldValue
78
+ ? (oldValue ?? null)
79
+ : oldValue;
80
+
81
+ tracker.changes[field] = {
82
+ oldValue: normalizedOldValue,
83
+ newValue,
84
+ };
85
+ };
38
86
 
39
- let payload;
87
+ /**
88
+ * Validate the request payload
89
+ */
90
+ const parsePayload = (body: unknown): TicketUpdatePayload => {
40
91
  try {
41
- payload = ticketUpdateSchema.parse(event.body);
92
+ return ticketUpdateSchema.parse(body);
42
93
  } catch (error) {
43
94
  if (error instanceof z.ZodError) {
44
95
  throw new ValidationError('Invalid ticket update payload', error.errors);
45
96
  }
46
97
  throw error;
47
98
  }
99
+ };
48
100
 
49
- const existing = await DynamoDBService.getItem<any>(
50
- {
51
- tableName: TICKETS_TABLE,
52
- key: id,
53
- },
101
+ /**
102
+ * Validate that the category exists and is active
103
+ */
104
+ const validateCategory = async (categoryId: string): Promise<void> => {
105
+ const category = await DynamoDBService.getItem<any>(
106
+ { tableName: CATEGORIES_TABLE, key: categoryId },
54
107
  logger
55
108
  );
56
-
57
- if (!existing) {
58
- throw new NotFoundError('Ticket not found');
59
- }
60
-
61
- if (payload.categoryId) {
62
- const category = await DynamoDBService.getItem<any>(
63
- {
64
- tableName: CATEGORIES_TABLE,
65
- key: payload.categoryId,
66
- },
67
- logger
68
- );
69
-
70
- if (!category || category.archivedAt) {
71
- throw new NotFoundError('Category not found');
72
- }
109
+ if (!category || category.archivedAt) {
110
+ throw new NotFoundError('Category not found');
73
111
  }
112
+ };
74
113
 
75
- const teamId = payload.teamId ?? existing.teamId;
76
- if (!teamId) {
77
- throw new ValidationError('Team is required', []);
78
- }
114
+ /**
115
+ * Get team and validate it exists, returns team members
116
+ */
117
+ const getTeamMembers = async (teamId: string): Promise<string[]> => {
79
118
  const team = await DynamoDBService.getItem<any>(
80
- {
81
- tableName: TEAMS_TABLE,
82
- key: teamId,
83
- },
119
+ { tableName: TEAMS_TABLE, key: teamId },
84
120
  logger
85
121
  );
86
122
  if (!team) {
87
123
  throw new NotFoundError('Team not found');
88
124
  }
89
- const members: string[] = Array.isArray(team.members) ? team.members : [];
90
- const assigneeToCheck =
91
- payload.assigneeUserId !== undefined ? payload.assigneeUserId : existing.assigneeUserId;
92
- if (assigneeToCheck && !members.includes(assigneeToCheck)) {
125
+ return Array.isArray(team.members) ? team.members : [];
126
+ };
127
+
128
+ /**
129
+ * Validate that the assignee belongs to the team
130
+ */
131
+ const validateAssignee = (
132
+ assigneeUserId: string | null | undefined,
133
+ teamMembers: string[]
134
+ ): void => {
135
+ if (assigneeUserId && !teamMembers.includes(assigneeUserId)) {
93
136
  throw new ValidationError('Assignee must belong to the team', []);
94
137
  }
138
+ };
95
139
 
140
+ /**
141
+ * Get support configuration (SLA settings)
142
+ */
143
+ const getSupportConfig = async (): Promise<{ sla: SlaConfig }> => {
96
144
  const config = await DynamoDBService.getItem<{
97
145
  id: string;
98
146
  notification?: { emailOnAssignment?: boolean };
99
147
  sla?: SlaConfig;
100
- }>(
101
- {
102
- tableName: SUPPORT_CONFIG_TABLE,
103
- key: 'support-config',
104
- },
105
- logger
106
- );
107
- const sla: SlaConfig = config?.sla ?? defaultSla;
108
-
109
- const updates: string[] = [];
110
- const values: Record<string, any> = {};
111
- const names: Record<string, string> = {};
112
-
113
- if (payload.priority !== undefined) {
114
- updates.push('priority = :priority');
115
- values[':priority'] = payload.priority ?? null;
116
- }
117
-
118
- if (payload.assigneeUserId !== undefined) {
119
- updates.push('assigneeUserId = :assigneeUserId');
120
- values[':assigneeUserId'] = payload.assigneeUserId ?? null;
121
- }
122
-
123
- if (payload.assigneeEmail !== undefined) {
124
- updates.push('assigneeEmail = :assigneeEmail');
125
- values[':assigneeEmail'] = payload.assigneeEmail ?? null;
126
- }
127
-
128
- if (payload.status) {
129
- updates.push('#status = :status');
130
- values[':status'] = payload.status;
131
- names['#status'] = 'status';
132
- }
133
-
134
- if (payload.categoryId) {
135
- updates.push('categoryId = :categoryId');
136
- values[':categoryId'] = payload.categoryId;
137
- }
138
-
139
- if (payload.teamId) {
140
- updates.push('teamId = :teamId');
141
- values[':teamId'] = payload.teamId;
142
- }
143
-
144
- if (payload.subject) {
145
- updates.push('#subject = :subject');
146
- values[':subject'] = payload.subject;
147
- names['#subject'] = 'subject';
148
- }
149
-
150
- if (payload.description !== undefined) {
151
- updates.push('description = :description');
152
- values[':description'] = payload.description ?? null;
153
- }
154
-
155
- if (payload.attachments !== undefined) {
156
- updates.push('attachments = :attachments');
157
- values[':attachments'] = payload.attachments;
158
- }
148
+ }>({ tableName: SUPPORT_CONFIG_TABLE, key: 'support-config' }, logger);
149
+ return { sla: config?.sla ?? DEFAULT_SLA };
150
+ };
159
151
 
160
- if (payload.requesterName) {
161
- updates.push('requesterName = :requesterName');
162
- values[':requesterName'] = payload.requesterName;
152
+ /**
153
+ * Build the DynamoDB update expression from payload
154
+ */
155
+ const buildUpdateExpression = (
156
+ payload: TicketUpdatePayload,
157
+ existing: any,
158
+ sla: SlaConfig,
159
+ shouldClearAssignee: boolean
160
+ ): { builder: UpdateExpressionBuilder; tracker: ChangeTracker } => {
161
+ const builder: UpdateExpressionBuilder = {
162
+ setExpressions: [],
163
+ removeExpressions: [],
164
+ values: {},
165
+ names: {},
166
+ };
167
+ const tracker: ChangeTracker = { changes: {} };
168
+
169
+ // Define field mappings: [payloadKey, options]
170
+ const fieldMappings: Array<{
171
+ key: keyof TicketUpdatePayload;
172
+ options?: { nullableOldValue?: boolean; arrayDefault?: boolean };
173
+ }> = [
174
+ { key: 'priority', options: { nullableOldValue: true } },
175
+ { key: 'assigneeUserId', options: { nullableOldValue: true } },
176
+ { key: 'assigneeEmail', options: { nullableOldValue: true } },
177
+ { key: 'status' },
178
+ { key: 'categoryId' },
179
+ { key: 'teamId' },
180
+ { key: 'subject' },
181
+ { key: 'description', options: { nullableOldValue: true } },
182
+ { key: 'attachments', options: { arrayDefault: true } },
183
+ { key: 'requesterName' },
184
+ { key: 'requesterEmail' },
185
+ ];
186
+
187
+ // Process each field from payload
188
+ for (const { key, options } of fieldMappings) {
189
+ if (payload[key] !== undefined) {
190
+ const newValue = payload[key] ?? null;
191
+ addFieldUpdate(key, newValue, existing[key], builder, tracker, options);
192
+ }
163
193
  }
164
194
 
165
- if (payload.requesterEmail) {
166
- updates.push('requesterEmail = :requesterEmail');
167
- values[':requesterEmail'] = payload.requesterEmail;
195
+ // Handle assignee clearing when team changes and assignee is not in new team
196
+ if (shouldClearAssignee && payload.assigneeUserId === undefined) {
197
+ addFieldUpdate('assigneeUserId', null, existing.assigneeUserId, builder, tracker, {
198
+ nullableOldValue: true,
199
+ });
200
+ addFieldUpdate('assigneeEmail', null, existing.assigneeEmail, builder, tracker, {
201
+ nullableOldValue: true,
202
+ });
168
203
  }
169
204
 
205
+ // Handle reminder based on status and priority
170
206
  const shouldClearReminder = payload.status === 'Resolved' || payload.status === 'Archived';
171
- const effectivePriority =
172
- payload.priority !== undefined ? payload.priority : (existing.priority ?? undefined);
207
+ const effectivePriority = payload.priority !== undefined ? payload.priority : existing.priority;
173
208
 
174
- const reminderAt = shouldClearReminder
175
- ? null
176
- : computeReminderAt(effectivePriority as string | undefined, sla);
177
-
178
- // Remove reminderAt entirely if it should be cleared (for sparse GSI)
179
- const removeExpressions: string[] = [];
180
209
  if (shouldClearReminder) {
181
- removeExpressions.push('reminderAt');
182
- } else if (reminderAt) {
183
- updates.push('reminderAt = :reminderAt');
184
- values[':reminderAt'] = reminderAt;
210
+ builder.removeExpressions.push('reminderAt');
211
+ } else {
212
+ const reminderAt = computeReminderAt(effectivePriority, sla);
213
+ if (reminderAt) {
214
+ builder.setExpressions.push('reminderAt = :reminderAt');
215
+ builder.values[':reminderAt'] = reminderAt;
216
+ }
185
217
  }
186
218
 
187
- updates.push('updatedAt = :updatedAt');
188
- values[':updatedAt'] = new Date().toISOString();
219
+ // Always update timestamp
220
+ builder.setExpressions.push('updatedAt = :updatedAt');
221
+ builder.values[':updatedAt'] = new Date().toISOString();
222
+
223
+ return { builder, tracker };
224
+ };
189
225
 
226
+ /**
227
+ * Execute the DynamoDB update
228
+ */
229
+ const executeUpdate = async (id: string, builder: UpdateExpressionBuilder): Promise<any> => {
190
230
  const expressionParts: string[] = [];
191
- if (updates.length > 0) {
192
- expressionParts.push(`SET ${updates.join(', ')}`);
231
+
232
+ if (builder.setExpressions.length > 0) {
233
+ expressionParts.push(`SET ${builder.setExpressions.join(', ')}`);
193
234
  }
194
- if (removeExpressions.length > 0) {
195
- expressionParts.push(`REMOVE ${removeExpressions.join(', ')}`);
235
+ if (builder.removeExpressions.length > 0) {
236
+ expressionParts.push(`REMOVE ${builder.removeExpressions.join(', ')}`);
196
237
  }
197
238
 
198
- const updated = await DynamoDBService.updateItem(
239
+ return DynamoDBService.updateItem(
199
240
  {
200
241
  tableName: TICKETS_TABLE,
201
242
  key: { id },
202
243
  updateExpression: expressionParts.join(' '),
203
- expressionAttributeValues: Object.keys(values).length ? values : undefined,
204
- expressionAttributeNames: Object.keys(names).length ? names : undefined,
244
+ expressionAttributeValues: Object.keys(builder.values).length
245
+ ? builder.values
246
+ : undefined,
247
+ expressionAttributeNames: Object.keys(builder.names).length ? builder.names : undefined,
205
248
  },
206
249
  logger
207
250
  );
251
+ };
208
252
 
209
- // Track the changes with old and new values
210
- const changes: Record<string, { oldValue: any; newValue: any }> = {};
211
-
212
- if (payload.priority !== undefined) {
213
- changes.priority = {
214
- oldValue: existing.priority ?? null,
215
- newValue: payload.priority ?? null,
216
- };
217
- }
218
-
219
- if (payload.assigneeUserId !== undefined) {
220
- changes.assigneeUserId = {
221
- oldValue: existing.assigneeUserId ?? null,
222
- newValue: payload.assigneeUserId ?? null,
223
- };
224
- }
225
-
226
- if (payload.assigneeEmail !== undefined) {
227
- changes.assigneeEmail = {
228
- oldValue: existing.assigneeEmail ?? null,
229
- newValue: payload.assigneeEmail ?? null,
230
- };
231
- }
232
-
233
- if (payload.status) {
234
- changes.status = {
235
- oldValue: existing.status,
236
- newValue: payload.status,
237
- };
238
- }
239
-
240
- if (payload.categoryId) {
241
- changes.categoryId = {
242
- oldValue: existing.categoryId,
243
- newValue: payload.categoryId,
244
- };
245
- }
246
-
247
- if (payload.teamId) {
248
- changes.teamId = {
249
- oldValue: existing.teamId,
250
- newValue: payload.teamId,
251
- };
252
- }
253
-
254
- if (payload.subject) {
255
- changes.subject = {
256
- oldValue: existing.subject,
257
- newValue: payload.subject,
258
- };
259
- }
260
-
261
- if (payload.description !== undefined) {
262
- changes.description = {
263
- oldValue: existing.description ?? null,
264
- newValue: payload.description ?? null,
265
- };
266
- }
267
-
268
- if (payload.attachments !== undefined) {
269
- changes.attachments = {
270
- oldValue: existing.attachments ?? [],
271
- newValue: payload.attachments,
272
- };
273
- }
274
-
275
- if (payload.requesterName) {
276
- changes.requesterName = {
277
- oldValue: existing.requesterName,
278
- newValue: payload.requesterName,
279
- };
280
- }
281
-
282
- if (payload.requesterEmail) {
283
- changes.requesterEmail = {
284
- oldValue: existing.requesterEmail,
285
- newValue: payload.requesterEmail,
286
- };
287
- }
288
-
253
+ /**
254
+ * Record the ticket update event for audit trail
255
+ */
256
+ const recordUpdateEvent = async (
257
+ ticketId: string,
258
+ changes: Record<string, { oldValue: any; newValue: any }>
259
+ ): Promise<void> => {
289
260
  await DynamoDBService.putItem(
290
261
  {
291
262
  tableName: TICKET_EVENTS_TABLE,
292
263
  item: {
293
264
  id: randomUUID(),
294
- ticketId: id,
265
+ ticketId,
295
266
  type: 'updated',
296
- payload: {
297
- changes,
298
- userId: event.requestContext?.authorizer?.jwt?.claims?.sub,
299
- },
267
+ payload: { changes },
300
268
  createdAt: new Date().toISOString(),
301
269
  },
302
270
  },
303
271
  logger
304
272
  );
273
+ };
305
274
 
306
- const safeUpdated = (updated as any) ?? {
307
- id,
308
- attachments: [],
309
- };
310
-
311
- // Queue assignment notification email if assignee changed and has email (processed async)
275
+ /**
276
+ * Send assignment notification if assignee changed
277
+ */
278
+ const sendAssignmentNotification = async (
279
+ ticketId: string,
280
+ payload: TicketUpdatePayload,
281
+ existing: any,
282
+ updated: any
283
+ ): Promise<void> => {
312
284
  const newAssigneeEmail = payload.assigneeEmail ?? existing.assigneeEmail;
313
285
  const assigneeChanged =
314
286
  payload.assigneeUserId !== undefined &&
@@ -320,16 +292,75 @@ const updateTicket = async (event: APIGatewayProxyEventV2) => {
320
292
  url: REMINDER_QUEUE,
321
293
  messages: {
322
294
  type: 'assignment',
323
- ticketId: id,
295
+ ticketId,
324
296
  assigneeEmail: newAssigneeEmail,
325
- ticketNumber: safeUpdated.ticketNumber ?? existing.ticketNumber,
326
- subject: safeUpdated.subject ?? existing.subject,
327
- priority: safeUpdated.priority ?? existing.priority,
328
- requesterName: safeUpdated.requesterName ?? existing.requesterName,
329
- requesterEmail: safeUpdated.requesterEmail ?? existing.requesterEmail,
297
+ ticketNumber: updated.ticketNumber ?? existing.ticketNumber,
298
+ subject: updated.subject ?? existing.subject,
299
+ priority: updated.priority ?? existing.priority,
300
+ requesterName: updated.requesterName ?? existing.requesterName,
301
+ requesterEmail: updated.requesterEmail ?? existing.requesterEmail,
330
302
  },
331
303
  });
332
304
  }
305
+ };
306
+
307
+ /**
308
+ * Main handler for updating a ticket
309
+ */
310
+ const updateTicket = async (event: APIGatewayProxyEventV2) => {
311
+ const id = event.pathParameters?.id;
312
+ if (!id) {
313
+ throw new ValidationError('Ticket id is required', []);
314
+ }
315
+
316
+ const payload = parsePayload(event.body);
317
+
318
+ // Fetch existing ticket
319
+ const existing = await DynamoDBService.getItem<any>(
320
+ { tableName: TICKETS_TABLE, key: id },
321
+ logger
322
+ );
323
+ if (!existing) {
324
+ throw new NotFoundError('Ticket not found');
325
+ }
326
+
327
+ // Validate category if being updated
328
+ if (payload.categoryId) {
329
+ await validateCategory(payload.categoryId);
330
+ }
331
+
332
+ // Validate team and get members
333
+ const teamId = payload.teamId ?? existing.teamId;
334
+ if (!teamId) {
335
+ throw new ValidationError('Team is required', []);
336
+ }
337
+ const teamMembers = await getTeamMembers(teamId);
338
+
339
+ // Determine if we need to clear the assignee (team changed and current assignee not in new team)
340
+ const teamChanged = payload.teamId && payload.teamId !== existing.teamId;
341
+ const currentAssignee =
342
+ payload.assigneeUserId !== undefined ? payload.assigneeUserId : existing.assigneeUserId;
343
+ const shouldClearAssignee =
344
+ teamChanged && currentAssignee && !teamMembers.includes(currentAssignee);
345
+
346
+ // Validate assignee (only if not being auto-cleared)
347
+ const effectiveAssignee = shouldClearAssignee ? null : currentAssignee;
348
+ validateAssignee(effectiveAssignee, teamMembers);
349
+
350
+ // Get SLA configuration
351
+ const { sla } = await getSupportConfig();
352
+
353
+ // Build and execute update
354
+ const { builder, tracker } = buildUpdateExpression(payload, existing, sla, shouldClearAssignee);
355
+ const updated = await executeUpdate(id, builder);
356
+
357
+ // Record update event
358
+ await recordUpdateEvent(id, tracker.changes);
359
+
360
+ const safeUpdated = (updated as any) ?? { id, attachments: [] };
361
+
362
+ // Send assignment notification if needed
363
+ await sendAssignmentNotification(id, payload, existing, safeUpdated);
333
364
 
334
365
  return createResponse({
335
366
  ...safeUpdated,
@@ -171,16 +171,15 @@ export function SupportStack({ stack }: StackContext) {
171
171
  },
172
172
  });
173
173
 
174
-
175
- const binds = [
176
- supportConfigTable,
177
- categoriesTable,
178
- teamsTable,
179
- ticketsTable,
180
- ticketEventsTable,
181
- attachmentsBucket,
182
- reminderQueue,
183
- ];
174
+ const binds = [
175
+ supportConfigTable,
176
+ categoriesTable,
177
+ teamsTable,
178
+ ticketsTable,
179
+ ticketEventsTable,
180
+ attachmentsBucket,
181
+ reminderQueue,
182
+ ];
184
183
 
185
184
  const notificationWorker = new Function(stack, 'support-notification-worker', {
186
185
  handler: 'packages/functions/workers/notificationWorker.handler',