@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.
|
|
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/
|
|
28
|
+
"@xcelsior/aws": "1.0.5",
|
|
29
|
+
"@xcelsior/support-client": "1.0.6",
|
|
29
30
|
"@xcelsior/email": "1.0.2",
|
|
30
|
-
"@xcelsior/
|
|
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",
|
|
@@ -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 {
|
|
26
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
21
|
+
type TicketUpdatePayload = z.infer<typeof ticketUpdateSchema>;
|
|
22
22
|
|
|
23
|
-
const
|
|
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 =
|
|
26
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Validate the request payload
|
|
89
|
+
*/
|
|
90
|
+
const parsePayload = (body: unknown): TicketUpdatePayload => {
|
|
40
91
|
try {
|
|
41
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
295
|
+
ticketId,
|
|
324
296
|
assigneeEmail: newAssigneeEmail,
|
|
325
|
-
ticketNumber:
|
|
326
|
-
subject:
|
|
327
|
-
priority:
|
|
328
|
-
requesterName:
|
|
329
|
-
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,
|
package/stacks/SupportStack.ts
CHANGED
|
@@ -171,16 +171,15 @@ export function SupportStack({ stack }: StackContext) {
|
|
|
171
171
|
},
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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',
|