@xcelsior/support-api 0.1.1

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.
@@ -0,0 +1,59 @@
1
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
2
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
3
+ import { ZodError } from 'zod';
4
+ import { createResponse, logger, middlewareConfig } from '../config';
5
+ import { ticketStatusFacetQuerySchema } from '../types';
6
+ import { buildTicketFilters, getTicketIndex } from '../search/ticketSearch';
7
+
8
+ const ticketStatusFacets = async (event: APIGatewayProxyEventV2) => {
9
+ let query;
10
+ try {
11
+ query = ticketStatusFacetQuerySchema.parse(event.queryStringParameters || {});
12
+ } catch (error) {
13
+ if (error instanceof ZodError) {
14
+ throw new ValidationError('Invalid query parameters', error.errors);
15
+ }
16
+ throw error;
17
+ }
18
+
19
+ const { status, priority, categoryId, assigneeUserId, teamId, search, facets } = query;
20
+
21
+ logger.info('Ticket status facet request', {
22
+ status,
23
+ priority,
24
+ categoryId,
25
+ assigneeUserId,
26
+ teamId,
27
+ search,
28
+ facets,
29
+ });
30
+
31
+ const filters = buildTicketFilters({ status, priority, categoryId, assigneeUserId, teamId });
32
+ const index = await getTicketIndex();
33
+
34
+ // Parse facets parameter - comma-separated list, defaults to ['status']
35
+ const facetsArray = facets
36
+ ? facets
37
+ .split(',')
38
+ .map((f) => f.trim())
39
+ .filter(Boolean)
40
+ : ['status'];
41
+
42
+ const result = await index.search(search ?? '', {
43
+ filter: filters.length ? filters.join(' AND ') : undefined,
44
+ facets: facetsArray,
45
+ limit: 0,
46
+ });
47
+
48
+ // Build response with only the requested facets
49
+ const response: Record<string, Record<string, number>> = {};
50
+ for (const facet of facetsArray) {
51
+ if (result.facetDistribution?.[facet]) {
52
+ response[facet] = result.facetDistribution[facet];
53
+ }
54
+ }
55
+
56
+ return createResponse(response);
57
+ };
58
+
59
+ export const handler = middyfy(ticketStatusFacets, middlewareConfig);
@@ -0,0 +1,340 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import {
3
+ CATEGORIES_TABLE,
4
+ createResponse,
5
+ logger,
6
+ middlewareConfig,
7
+ REMINDER_QUEUE,
8
+ SUPPORT_CONFIG_TABLE,
9
+ TEAMS_TABLE,
10
+ TICKET_EVENTS_TABLE,
11
+ TICKETS_TABLE,
12
+ } from '../config';
13
+ import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
14
+ import { ticketUpdateSchema } from '../types';
15
+ import { z } from 'zod';
16
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
17
+ import { SqsService } from '@xcelsior/aws/src/sqs';
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ type SlaConfig = { low?: number; medium?: number; high?: number; critical?: number };
21
+ const defaultSla: Required<SlaConfig> = { low: 7, medium: 5, high: 3, critical: 1 };
22
+
23
+ const computeReminderAt = (priority?: string, sla?: SlaConfig) => {
24
+ if (!priority) return undefined;
25
+ const days = (sla?.[priority as keyof SlaConfig] ??
26
+ defaultSla[priority as keyof typeof defaultSla]) as number | undefined;
27
+ if (!days) return undefined;
28
+ const date = new Date();
29
+ date.setDate(date.getDate() + days);
30
+ return date.toISOString();
31
+ };
32
+
33
+ const updateTicket = async (event: APIGatewayProxyEventV2) => {
34
+ const id = event.pathParameters?.id;
35
+ if (!id) {
36
+ throw new ValidationError('Ticket id is required', []);
37
+ }
38
+
39
+ let payload;
40
+ try {
41
+ payload = ticketUpdateSchema.parse(event.body);
42
+ } catch (error) {
43
+ if (error instanceof z.ZodError) {
44
+ throw new ValidationError('Invalid ticket update payload', error.errors);
45
+ }
46
+ throw error;
47
+ }
48
+
49
+ const existing = await DynamoDBService.getItem<any>(
50
+ {
51
+ tableName: TICKETS_TABLE,
52
+ key: id,
53
+ },
54
+ logger
55
+ );
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
+ }
73
+ }
74
+
75
+ const teamId = payload.teamId ?? existing.teamId;
76
+ if (!teamId) {
77
+ throw new ValidationError('Team is required', []);
78
+ }
79
+ const team = await DynamoDBService.getItem<any>(
80
+ {
81
+ tableName: TEAMS_TABLE,
82
+ key: teamId,
83
+ },
84
+ logger
85
+ );
86
+ if (!team) {
87
+ throw new NotFoundError('Team not found');
88
+ }
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)) {
93
+ throw new ValidationError('Assignee must belong to the team', []);
94
+ }
95
+
96
+ const config = await DynamoDBService.getItem<{
97
+ id: string;
98
+ notification?: { emailOnAssignment?: boolean };
99
+ 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
+ }
159
+
160
+ if (payload.requesterName) {
161
+ updates.push('requesterName = :requesterName');
162
+ values[':requesterName'] = payload.requesterName;
163
+ }
164
+
165
+ if (payload.requesterEmail) {
166
+ updates.push('requesterEmail = :requesterEmail');
167
+ values[':requesterEmail'] = payload.requesterEmail;
168
+ }
169
+
170
+ const shouldClearReminder = payload.status === 'Resolved' || payload.status === 'Archived';
171
+ const effectivePriority =
172
+ payload.priority !== undefined ? payload.priority : (existing.priority ?? undefined);
173
+
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
+ if (shouldClearReminder) {
181
+ removeExpressions.push('reminderAt');
182
+ } else if (reminderAt) {
183
+ updates.push('reminderAt = :reminderAt');
184
+ values[':reminderAt'] = reminderAt;
185
+ }
186
+
187
+ updates.push('updatedAt = :updatedAt');
188
+ values[':updatedAt'] = new Date().toISOString();
189
+
190
+ const expressionParts: string[] = [];
191
+ if (updates.length > 0) {
192
+ expressionParts.push(`SET ${updates.join(', ')}`);
193
+ }
194
+ if (removeExpressions.length > 0) {
195
+ expressionParts.push(`REMOVE ${removeExpressions.join(', ')}`);
196
+ }
197
+
198
+ const updated = await DynamoDBService.updateItem(
199
+ {
200
+ tableName: TICKETS_TABLE,
201
+ key: { id },
202
+ updateExpression: expressionParts.join(' '),
203
+ expressionAttributeValues: Object.keys(values).length ? values : undefined,
204
+ expressionAttributeNames: Object.keys(names).length ? names : undefined,
205
+ },
206
+ logger
207
+ );
208
+
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
+
289
+ await DynamoDBService.putItem(
290
+ {
291
+ tableName: TICKET_EVENTS_TABLE,
292
+ item: {
293
+ id: randomUUID(),
294
+ ticketId: id,
295
+ type: 'updated',
296
+ payload: {
297
+ changes,
298
+ userId: event.requestContext?.authorizer?.jwt?.claims?.sub,
299
+ },
300
+ createdAt: new Date().toISOString(),
301
+ },
302
+ },
303
+ logger
304
+ );
305
+
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)
312
+ const newAssigneeEmail = payload.assigneeEmail ?? existing.assigneeEmail;
313
+ const assigneeChanged =
314
+ payload.assigneeUserId !== undefined &&
315
+ payload.assigneeUserId !== existing.assigneeUserId &&
316
+ payload.assigneeUserId !== null;
317
+
318
+ if (assigneeChanged && newAssigneeEmail) {
319
+ await SqsService.sendMessage({
320
+ url: REMINDER_QUEUE,
321
+ messages: {
322
+ type: 'assignment',
323
+ ticketId: id,
324
+ 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,
330
+ },
331
+ });
332
+ }
333
+
334
+ return createResponse({
335
+ ...safeUpdated,
336
+ attachments: safeUpdated.attachments ?? [],
337
+ });
338
+ };
339
+
340
+ export const handler = middyfy(updateTicket, middlewareConfig);
@@ -0,0 +1,15 @@
1
+ export type { SupportApiResponse as ApiResponse } from '@xcelsior/support-client';
2
+
3
+ export {
4
+ supportConfigSchema,
5
+ categoryCreateSchema,
6
+ categoryUpdateSchema,
7
+ teamCreateSchema,
8
+ teamMemberSchema,
9
+ ticketCreateSchema,
10
+ ticketUpdateSchema,
11
+ ticketListQuerySchema,
12
+ ticketStatusFacetQuerySchema,
13
+ ticketStatusFacetSchema,
14
+ attachmentUploadRequestSchema,
15
+ } from '@xcelsior/support-client';
@@ -0,0 +1,11 @@
1
+ import type { SQSEvent } from 'aws-lambda';
2
+ import { logger, middlewareConfig } from '../config';
3
+ import { middyfy } from '@xcelsior/lambda-http';
4
+
5
+ const reminderSweep = async (event: SQSEvent) => {
6
+ logger.info('Reminder sweep - stub', {
7
+ recordCount: event.Records.length,
8
+ });
9
+ };
10
+
11
+ export const handler = middyfy(reminderSweep, middlewareConfig);
package/sst.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import 'dotenv/config';
2
+ import type { SSTConfig } from 'sst';
3
+ import { SupportStack } from './stacks/SupportStack';
4
+
5
+ export default {
6
+ config(_input) {
7
+ return {
8
+ name: 'support',
9
+ region: 'ap-southeast-2',
10
+ };
11
+ },
12
+ stacks(app) {
13
+ app.stack(SupportStack);
14
+ },
15
+ } satisfies SSTConfig;