@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.
- package/cdk.context.json +6 -0
- package/package.json +42 -0
- package/packages/functions/attachments/generateUploadUrl.ts +55 -0
- package/packages/functions/categories/archive.ts +57 -0
- package/packages/functions/categories/create.ts +44 -0
- package/packages/functions/categories/list.ts +29 -0
- package/packages/functions/categories/update.ts +73 -0
- package/packages/functions/config/get.ts +40 -0
- package/packages/functions/config/update.ts +44 -0
- package/packages/functions/config.ts +40 -0
- package/packages/functions/events/list.ts +48 -0
- package/packages/functions/search/ticketSearch.ts +128 -0
- package/packages/functions/services/emailService.ts +160 -0
- package/packages/functions/teams/addMember.ts +69 -0
- package/packages/functions/teams/create.ts +46 -0
- package/packages/functions/teams/list.ts +31 -0
- package/packages/functions/teams/removeMember.ts +56 -0
- package/packages/functions/tickets/create.ts +179 -0
- package/packages/functions/tickets/get.ts +27 -0
- package/packages/functions/tickets/list.ts +63 -0
- package/packages/functions/tickets/searchSync.ts +43 -0
- package/packages/functions/tickets/statusFacets.ts +59 -0
- package/packages/functions/tickets/update.ts +340 -0
- package/packages/functions/types.ts +15 -0
- package/packages/functions/workers/reminderSweep.ts +11 -0
- package/sst.config.ts +15 -0
- package/stacks/SupportStack.ts +387 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { emailTransporter } from '@xcelsior/email';
|
|
2
|
+
import { logger } from '../config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if email is configured - email sending is optional
|
|
6
|
+
*/
|
|
7
|
+
export const isEmailConfigured = (): boolean => {
|
|
8
|
+
const transport = process.env.EMAIL_TRANSPORT;
|
|
9
|
+
if (!transport) return false;
|
|
10
|
+
|
|
11
|
+
if (transport === 'smtp') {
|
|
12
|
+
return Boolean(process.env.SMTP_HOST);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// For SES transport
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface AssignmentEmailOptions {
|
|
20
|
+
to: string;
|
|
21
|
+
ticketNumber: number;
|
|
22
|
+
ticketId: string;
|
|
23
|
+
subject: string;
|
|
24
|
+
priority?: string | null;
|
|
25
|
+
requesterName: string;
|
|
26
|
+
requesterEmail: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const sendAssignmentEmail = async (options: AssignmentEmailOptions): Promise<void> => {
|
|
30
|
+
if (!isEmailConfigured()) {
|
|
31
|
+
logger.info('Email not configured, skipping assignment notification');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { to, ticketNumber, ticketId, subject, priority, requesterName, requesterEmail } =
|
|
36
|
+
options;
|
|
37
|
+
|
|
38
|
+
const emailSender = process.env.EMAIL_SENDER || 'noreply@xcelsior.au';
|
|
39
|
+
const appUrl = process.env.APP_URL || '';
|
|
40
|
+
const ticketUrl = appUrl ? `${appUrl}/support/tickets/${ticketId}` : '';
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const transporter = emailTransporter(logger);
|
|
44
|
+
await transporter.sendMail({
|
|
45
|
+
to,
|
|
46
|
+
from: emailSender,
|
|
47
|
+
subject: `[Ticket #${ticketNumber}] You have been assigned: ${subject}`,
|
|
48
|
+
text: `You have been assigned to support ticket #${ticketNumber}.\n\nSubject: ${subject}\nPriority: ${priority || 'Not set'}\nRequester: ${requesterName} (${requesterEmail})\n\n${ticketUrl ? `View ticket: ${ticketUrl}` : ''}`,
|
|
49
|
+
template: `
|
|
50
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
51
|
+
<h2>New Ticket Assignment</h2>
|
|
52
|
+
<p>You have been assigned to support ticket <strong>#${ticketNumber}</strong>.</p>
|
|
53
|
+
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
|
54
|
+
<p><strong>Subject:</strong> ${subject}</p>
|
|
55
|
+
<p><strong>Priority:</strong> ${priority || 'Not set'}</p>
|
|
56
|
+
<p><strong>Requester:</strong> ${requesterName} (${requesterEmail})</p>
|
|
57
|
+
</div>
|
|
58
|
+
${ticketUrl ? `<p><a href="${ticketUrl}" style="display: inline-block; background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">View Ticket</a></p>` : ''}
|
|
59
|
+
</div>
|
|
60
|
+
`,
|
|
61
|
+
reference: `ticket-assignment-${ticketId}`,
|
|
62
|
+
idempotencyKey: `ticket-assignment-${ticketId}-${to}`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
logger.info('Assignment notification email sent', {
|
|
66
|
+
ticketId,
|
|
67
|
+
ticketNumber,
|
|
68
|
+
assigneeEmail: to,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
logger.error('Failed to send assignment notification email', {
|
|
72
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
73
|
+
ticketId,
|
|
74
|
+
ticketNumber,
|
|
75
|
+
assigneeEmail: to,
|
|
76
|
+
});
|
|
77
|
+
// Don't throw - email failure shouldn't fail the ticket operation
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface ReminderEmailOptions {
|
|
82
|
+
to: string;
|
|
83
|
+
ticketNumber: number;
|
|
84
|
+
ticketId: string;
|
|
85
|
+
subject: string;
|
|
86
|
+
priority?: string | null;
|
|
87
|
+
status: string;
|
|
88
|
+
requesterName: string;
|
|
89
|
+
requesterEmail: string;
|
|
90
|
+
reminderAt: string;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const sendReminderEmail = async (options: ReminderEmailOptions): Promise<void> => {
|
|
95
|
+
if (!isEmailConfigured()) {
|
|
96
|
+
logger.info('Email not configured, skipping reminder notification');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
to,
|
|
102
|
+
ticketNumber,
|
|
103
|
+
ticketId,
|
|
104
|
+
subject,
|
|
105
|
+
priority,
|
|
106
|
+
status,
|
|
107
|
+
requesterName,
|
|
108
|
+
requesterEmail,
|
|
109
|
+
reminderAt,
|
|
110
|
+
createdAt,
|
|
111
|
+
} = options;
|
|
112
|
+
|
|
113
|
+
const emailSender = process.env.EMAIL_SENDER || 'noreply@xcelsior.au';
|
|
114
|
+
const appUrl = process.env.APP_URL || '';
|
|
115
|
+
const ticketUrl = appUrl ? `${appUrl}/support/tickets/${ticketId}` : '';
|
|
116
|
+
|
|
117
|
+
const createdDate = new Date(createdAt).toLocaleDateString();
|
|
118
|
+
const reminderDate = new Date(reminderAt).toLocaleDateString();
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const transporter = emailTransporter(logger);
|
|
122
|
+
await transporter.sendMail({
|
|
123
|
+
to,
|
|
124
|
+
from: emailSender,
|
|
125
|
+
subject: `[Reminder] Ticket #${ticketNumber} requires attention: ${subject}`,
|
|
126
|
+
text: `This is a reminder that ticket #${ticketNumber} has not been resolved and requires your attention.\n\nSubject: ${subject}\nStatus: ${status}\nPriority: ${priority || 'Not set'}\nCreated: ${createdDate}\nDue: ${reminderDate}\nRequester: ${requesterName} (${requesterEmail})\n\n${ticketUrl ? `View ticket: ${ticketUrl}` : ''}`,
|
|
127
|
+
template: `
|
|
128
|
+
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
129
|
+
<h2 style="color: #dc3545;">⏰ Ticket Reminder</h2>
|
|
130
|
+
<p>This is a reminder that ticket <strong>#${ticketNumber}</strong> has not been resolved and requires your attention.</p>
|
|
131
|
+
<div style="background-color: #fff3cd; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #ffc107;">
|
|
132
|
+
<p><strong>Subject:</strong> ${subject}</p>
|
|
133
|
+
<p><strong>Status:</strong> ${status}</p>
|
|
134
|
+
<p><strong>Priority:</strong> ${priority || 'Not set'}</p>
|
|
135
|
+
<p><strong>Created:</strong> ${createdDate}</p>
|
|
136
|
+
<p><strong>Due:</strong> ${reminderDate}</p>
|
|
137
|
+
<p><strong>Requester:</strong> ${requesterName} (${requesterEmail})</p>
|
|
138
|
+
</div>
|
|
139
|
+
${ticketUrl ? `<p><a href="${ticketUrl}" style="display: inline-block; background-color: #dc3545; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;">View Ticket</a></p>` : ''}
|
|
140
|
+
</div>
|
|
141
|
+
`,
|
|
142
|
+
reference: `ticket-reminder-${ticketId}`,
|
|
143
|
+
idempotencyKey: `ticket-reminder-${ticketId}-${reminderAt}`,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
logger.info('Reminder notification email sent', {
|
|
147
|
+
ticketId,
|
|
148
|
+
ticketNumber,
|
|
149
|
+
assigneeEmail: to,
|
|
150
|
+
});
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logger.error('Failed to send reminder notification email', {
|
|
153
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
154
|
+
ticketId,
|
|
155
|
+
ticketNumber,
|
|
156
|
+
assigneeEmail: to,
|
|
157
|
+
});
|
|
158
|
+
// Don't throw - email failure shouldn't fail the reminder operation
|
|
159
|
+
}
|
|
160
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
2
|
+
import { createResponse, logger, middlewareConfig, TEAMS_TABLE } from '../config';
|
|
3
|
+
import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
|
|
4
|
+
import { teamMemberSchema } from '../types';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
7
|
+
|
|
8
|
+
const addTeamMember = async (event: APIGatewayProxyEventV2) => {
|
|
9
|
+
let payload;
|
|
10
|
+
try {
|
|
11
|
+
payload = teamMemberSchema.parse(event.body);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error instanceof z.ZodError) {
|
|
14
|
+
throw new ValidationError('Invalid member payload', error.errors);
|
|
15
|
+
}
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const id = event.pathParameters?.id;
|
|
20
|
+
if (!id) {
|
|
21
|
+
throw new ValidationError('Team id is required', []);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const team = await DynamoDBService.getItem<any>(
|
|
25
|
+
{
|
|
26
|
+
tableName: TEAMS_TABLE,
|
|
27
|
+
key: id,
|
|
28
|
+
},
|
|
29
|
+
logger
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (!team) {
|
|
33
|
+
throw new NotFoundError('Team not found');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const members: string[] = Array.isArray(team.members) ? team.members : [];
|
|
37
|
+
if (!members.includes(payload.userId)) {
|
|
38
|
+
members.push(payload.userId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const updated = await DynamoDBService.updateItem(
|
|
42
|
+
{
|
|
43
|
+
tableName: TEAMS_TABLE,
|
|
44
|
+
key: { id },
|
|
45
|
+
updateExpression: 'SET members = :members, updatedAt = :updatedAt',
|
|
46
|
+
expressionAttributeValues: {
|
|
47
|
+
':members': members,
|
|
48
|
+
':updatedAt': new Date().toISOString(),
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
logger
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
logger.info('Add team member', { teamId: id, userId: payload.userId });
|
|
55
|
+
|
|
56
|
+
const safeUpdated = (updated as any) ?? {
|
|
57
|
+
id,
|
|
58
|
+
isDefault: false,
|
|
59
|
+
members,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return createResponse({
|
|
63
|
+
...safeUpdated,
|
|
64
|
+
isDefault: safeUpdated.isDefault === true,
|
|
65
|
+
members: safeUpdated.members ?? members,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const handler = middyfy(addTeamMember, middlewareConfig);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
2
|
+
import { createResponse, logger, middlewareConfig, TEAMS_TABLE } from '../config';
|
|
3
|
+
import { middyfy, ValidationError } from '@xcelsior/lambda-http';
|
|
4
|
+
import { teamCreateSchema } from '../types';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
8
|
+
|
|
9
|
+
const createTeam = async (event: APIGatewayProxyEventV2) => {
|
|
10
|
+
let payload;
|
|
11
|
+
try {
|
|
12
|
+
payload = teamCreateSchema.parse(event.body);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof z.ZodError) {
|
|
15
|
+
throw new ValidationError('Invalid team payload', error.errors);
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const now = new Date().toISOString();
|
|
21
|
+
const team = {
|
|
22
|
+
id: randomUUID(),
|
|
23
|
+
name: payload.name,
|
|
24
|
+
type: payload.type,
|
|
25
|
+
isDefault: false,
|
|
26
|
+
members: [],
|
|
27
|
+
createdAt: now,
|
|
28
|
+
updatedAt: now,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
logger.info('Create team', { teamId: team.id });
|
|
32
|
+
|
|
33
|
+
await DynamoDBService.putItem(
|
|
34
|
+
{
|
|
35
|
+
tableName: TEAMS_TABLE,
|
|
36
|
+
item: team,
|
|
37
|
+
},
|
|
38
|
+
logger
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return createResponse({
|
|
42
|
+
...team,
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const handler = middyfy(createTeam, middlewareConfig);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
2
|
+
import { createResponse, logger, middlewareConfig, TEAMS_TABLE } from '../config';
|
|
3
|
+
import { middyfy } from '@xcelsior/lambda-http';
|
|
4
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
5
|
+
|
|
6
|
+
const listTeams = async (_event: APIGatewayProxyEventV2) => {
|
|
7
|
+
logger.info('List teams');
|
|
8
|
+
|
|
9
|
+
const result = await DynamoDBService.scanItems<any>(
|
|
10
|
+
{
|
|
11
|
+
tableName: TEAMS_TABLE,
|
|
12
|
+
filterExpression: 'attribute_not_exists(archivedAt)',
|
|
13
|
+
},
|
|
14
|
+
logger
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const items = result.items.map((item) => ({
|
|
18
|
+
...item,
|
|
19
|
+
isDefault: item.isDefault === true,
|
|
20
|
+
members: item.members ?? [],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
return createResponse({
|
|
24
|
+
items,
|
|
25
|
+
nextToken: result.nextToken
|
|
26
|
+
? Buffer.from(JSON.stringify(result.nextToken)).toString('base64')
|
|
27
|
+
: undefined,
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const handler = middyfy(listTeams, middlewareConfig);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
2
|
+
import { createResponse, logger, middlewareConfig, TEAMS_TABLE } from '../config';
|
|
3
|
+
import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
|
|
4
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
5
|
+
|
|
6
|
+
const removeTeamMember = async (event: APIGatewayProxyEventV2) => {
|
|
7
|
+
const id = event.pathParameters?.id;
|
|
8
|
+
const userId = event.pathParameters?.userId;
|
|
9
|
+
if (!id || !userId) {
|
|
10
|
+
throw new ValidationError('Team id and user id are required', []);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const team = await DynamoDBService.getItem<any>(
|
|
14
|
+
{
|
|
15
|
+
tableName: TEAMS_TABLE,
|
|
16
|
+
key: id,
|
|
17
|
+
},
|
|
18
|
+
logger
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!team) {
|
|
22
|
+
throw new NotFoundError('Team not found');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const members: string[] = Array.isArray(team.members) ? team.members : [];
|
|
26
|
+
const updatedMembers = members.filter((m) => m !== userId);
|
|
27
|
+
|
|
28
|
+
const updated = await DynamoDBService.updateItem(
|
|
29
|
+
{
|
|
30
|
+
tableName: TEAMS_TABLE,
|
|
31
|
+
key: { id },
|
|
32
|
+
updateExpression: 'SET members = :members, updatedAt = :updatedAt',
|
|
33
|
+
expressionAttributeValues: {
|
|
34
|
+
':members': updatedMembers,
|
|
35
|
+
':updatedAt': new Date().toISOString(),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
logger
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
logger.info('Remove team member', { teamId: id, userId });
|
|
42
|
+
|
|
43
|
+
const safeUpdated = (updated as any) ?? {
|
|
44
|
+
id,
|
|
45
|
+
isDefault: false,
|
|
46
|
+
members: updatedMembers,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return createResponse({
|
|
50
|
+
...safeUpdated,
|
|
51
|
+
isDefault: safeUpdated.isDefault === true,
|
|
52
|
+
members: safeUpdated.members ?? updatedMembers,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const handler = middyfy(removeTeamMember, middlewareConfig);
|
|
@@ -0,0 +1,179 @@
|
|
|
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 { ticketCreateSchema } from '../types';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
18
|
+
import { SqsService } from '@xcelsior/aws/src/sqs';
|
|
19
|
+
|
|
20
|
+
type SlaConfig = { low?: number; medium?: number; high?: number; critical?: number };
|
|
21
|
+
|
|
22
|
+
const defaultSla: Required<SlaConfig> = { low: 7, medium: 5, high: 3, critical: 1 };
|
|
23
|
+
|
|
24
|
+
const computeReminderAt = (priority?: string, sla?: SlaConfig) => {
|
|
25
|
+
const days =
|
|
26
|
+
((sla?.[priority as keyof SlaConfig] ?? defaultSla[priority as keyof typeof defaultSla]) as
|
|
27
|
+
| number
|
|
28
|
+
| undefined) || 5;
|
|
29
|
+
const date = new Date();
|
|
30
|
+
date.setDate(date.getDate() + days);
|
|
31
|
+
return date.toISOString();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const createTicket = async (event: APIGatewayProxyEventV2) => {
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = ticketCreateSchema.parse(event.body);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof z.ZodError) {
|
|
40
|
+
throw new ValidationError('Invalid ticket payload', error.errors);
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const now = new Date().toISOString();
|
|
46
|
+
|
|
47
|
+
const config = await DynamoDBService.getItem<{
|
|
48
|
+
id: string;
|
|
49
|
+
notification?: { emailOnAssignment?: boolean };
|
|
50
|
+
sla?: SlaConfig;
|
|
51
|
+
}>(
|
|
52
|
+
{
|
|
53
|
+
tableName: SUPPORT_CONFIG_TABLE,
|
|
54
|
+
key: 'support-config',
|
|
55
|
+
},
|
|
56
|
+
logger
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const sla: SlaConfig = config?.sla ?? defaultSla;
|
|
60
|
+
|
|
61
|
+
const category = await DynamoDBService.getItem<any>(
|
|
62
|
+
{
|
|
63
|
+
tableName: CATEGORIES_TABLE,
|
|
64
|
+
key: payload.categoryId,
|
|
65
|
+
},
|
|
66
|
+
logger
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!category || category.archivedAt) {
|
|
70
|
+
throw new NotFoundError('Category not found');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const team = await DynamoDBService.getItem<any>(
|
|
74
|
+
{
|
|
75
|
+
tableName: TEAMS_TABLE,
|
|
76
|
+
key: payload.teamId,
|
|
77
|
+
},
|
|
78
|
+
logger
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!team) {
|
|
82
|
+
throw new NotFoundError('Team not found');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const members: string[] = Array.isArray(team.members) ? team.members : [];
|
|
86
|
+
if (payload.assigneeUserId && !members.includes(payload.assigneeUserId)) {
|
|
87
|
+
throw new ValidationError('Assignee must belong to the team', []);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const attachments = payload.attachments ?? [];
|
|
91
|
+
const reminderAt =
|
|
92
|
+
payload.priority && payload.priority !== undefined
|
|
93
|
+
? computeReminderAt(payload.priority, sla)
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
// Generate sequential ticket number using atomic counter
|
|
97
|
+
const ticketNumber = await DynamoDBService.incrementCounter(
|
|
98
|
+
{
|
|
99
|
+
tableName: SUPPORT_CONFIG_TABLE,
|
|
100
|
+
key: 'ticket-counter',
|
|
101
|
+
attributeName: 'counter',
|
|
102
|
+
},
|
|
103
|
+
logger
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const ticket = {
|
|
107
|
+
id: randomUUID(),
|
|
108
|
+
ticketNumber,
|
|
109
|
+
categoryId: payload.categoryId,
|
|
110
|
+
teamId: payload.teamId,
|
|
111
|
+
subject: payload.subject,
|
|
112
|
+
description: payload.description ?? null,
|
|
113
|
+
status: 'New',
|
|
114
|
+
priority: payload.priority ?? null,
|
|
115
|
+
assigneeUserId: payload.assigneeUserId ?? null,
|
|
116
|
+
assigneeEmail: payload.assigneeEmail ?? null,
|
|
117
|
+
requesterName: payload.requesterName,
|
|
118
|
+
requesterEmail: payload.requesterEmail,
|
|
119
|
+
attachments,
|
|
120
|
+
reminderAt,
|
|
121
|
+
createdAt: now,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
logger.info('Create ticket', { ticketId: ticket.id });
|
|
126
|
+
|
|
127
|
+
await DynamoDBService.putItem(
|
|
128
|
+
{
|
|
129
|
+
tableName: TICKETS_TABLE,
|
|
130
|
+
item: ticket,
|
|
131
|
+
},
|
|
132
|
+
logger
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await DynamoDBService.putItem(
|
|
136
|
+
{
|
|
137
|
+
tableName: TICKET_EVENTS_TABLE,
|
|
138
|
+
item: {
|
|
139
|
+
id: randomUUID(),
|
|
140
|
+
ticketId: ticket.id,
|
|
141
|
+
type: 'created',
|
|
142
|
+
payload: {
|
|
143
|
+
requester: ticket.requesterEmail,
|
|
144
|
+
ticket: {
|
|
145
|
+
categoryId: ticket.categoryId,
|
|
146
|
+
teamId: ticket.teamId,
|
|
147
|
+
subject: ticket.subject,
|
|
148
|
+
priority: ticket.priority,
|
|
149
|
+
assigneeUserId: ticket.assigneeUserId,
|
|
150
|
+
status: ticket.status,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
createdAt: now,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
logger
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Queue assignment notification email (processed async)
|
|
160
|
+
if (ticket.assigneeEmail && ticket.assigneeUserId) {
|
|
161
|
+
await SqsService.sendMessage({
|
|
162
|
+
url: REMINDER_QUEUE,
|
|
163
|
+
messages: {
|
|
164
|
+
type: 'assignment',
|
|
165
|
+
ticketId: ticket.id,
|
|
166
|
+
assigneeEmail: ticket.assigneeEmail,
|
|
167
|
+
ticketNumber: ticket.ticketNumber,
|
|
168
|
+
subject: ticket.subject,
|
|
169
|
+
priority: ticket.priority,
|
|
170
|
+
requesterName: ticket.requesterName,
|
|
171
|
+
requesterEmail: ticket.requesterEmail,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return createResponse(ticket);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const handler = middyfy(createTicket, middlewareConfig);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { APIGatewayProxyEventV2 } from 'aws-lambda';
|
|
2
|
+
import { createResponse, logger, middlewareConfig, TICKETS_TABLE } from '../config';
|
|
3
|
+
import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
|
|
4
|
+
import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
|
|
5
|
+
|
|
6
|
+
const getTicket = async (event: APIGatewayProxyEventV2) => {
|
|
7
|
+
const id = event.pathParameters?.id;
|
|
8
|
+
if (!id) {
|
|
9
|
+
throw new ValidationError('Ticket id is required', []);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ticket = await DynamoDBService.getItem<any>(
|
|
13
|
+
{
|
|
14
|
+
tableName: TICKETS_TABLE,
|
|
15
|
+
key: id,
|
|
16
|
+
},
|
|
17
|
+
logger
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
if (!ticket) {
|
|
21
|
+
throw new NotFoundError('Ticket not found');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return createResponse(ticket);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const handler = middyfy(getTicket, middlewareConfig);
|
|
@@ -0,0 +1,63 @@
|
|
|
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 { ticketListQuerySchema } from '../types';
|
|
6
|
+
import {
|
|
7
|
+
buildTicketFilters,
|
|
8
|
+
decodeOffsetToken,
|
|
9
|
+
encodeOffsetToken,
|
|
10
|
+
getTicketIndex,
|
|
11
|
+
type TicketSearchDocument,
|
|
12
|
+
} from '../search/ticketSearch';
|
|
13
|
+
|
|
14
|
+
const listTickets = async (event: APIGatewayProxyEventV2) => {
|
|
15
|
+
let query;
|
|
16
|
+
try {
|
|
17
|
+
query = ticketListQuerySchema.parse(event.queryStringParameters || {});
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error instanceof ZodError) {
|
|
20
|
+
throw new ValidationError('Invalid query parameters', error.errors);
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { status, priority, categoryId, assigneeUserId, teamId, search, limit, nextToken } =
|
|
26
|
+
query;
|
|
27
|
+
|
|
28
|
+
const offset = decodeOffsetToken(nextToken);
|
|
29
|
+
logger.info('List tickets', {
|
|
30
|
+
status,
|
|
31
|
+
priority,
|
|
32
|
+
categoryId,
|
|
33
|
+
assigneeUserId,
|
|
34
|
+
teamId,
|
|
35
|
+
search,
|
|
36
|
+
limit,
|
|
37
|
+
offset,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const filters = buildTicketFilters({ status, priority, categoryId, assigneeUserId, teamId });
|
|
41
|
+
const index = await getTicketIndex();
|
|
42
|
+
|
|
43
|
+
const searchResult = await index.search<TicketSearchDocument>(search ?? '', {
|
|
44
|
+
filter: filters.length ? filters.join(' AND ') : undefined,
|
|
45
|
+
sort: ['createdAt:desc'],
|
|
46
|
+
limit,
|
|
47
|
+
offset,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return createResponse({
|
|
51
|
+
items: (searchResult.hits ?? []).map((hit: TicketSearchDocument) => ({
|
|
52
|
+
...hit,
|
|
53
|
+
attachments: hit.attachments ?? [],
|
|
54
|
+
})),
|
|
55
|
+
nextToken:
|
|
56
|
+
typeof searchResult.estimatedTotalHits === 'number' &&
|
|
57
|
+
offset + (searchResult.hits?.length ?? 0) < searchResult.estimatedTotalHits
|
|
58
|
+
? encodeOffsetToken(offset + (searchResult.hits?.length ?? 0))
|
|
59
|
+
: undefined,
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const handler = middyfy(listTickets, middlewareConfig);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DynamoDBStreamHandler } from 'aws-lambda';
|
|
2
|
+
import { unmarshall } from '@aws-sdk/util-dynamodb';
|
|
3
|
+
import { logger } from '../config';
|
|
4
|
+
import { getTicketIndex, mapTicketToDocument } from '../search/ticketSearch';
|
|
5
|
+
|
|
6
|
+
export const handler: DynamoDBStreamHandler = async (event) => {
|
|
7
|
+
const index = await getTicketIndex();
|
|
8
|
+
|
|
9
|
+
for (const record of event.Records) {
|
|
10
|
+
try {
|
|
11
|
+
if (record.eventName === 'REMOVE') {
|
|
12
|
+
const key = record.dynamodb?.Keys
|
|
13
|
+
? unmarshall(record.dynamodb.Keys as any)
|
|
14
|
+
: undefined;
|
|
15
|
+
const id = (key as any)?.id;
|
|
16
|
+
if (id) {
|
|
17
|
+
await index.deleteDocument(id);
|
|
18
|
+
logger.info('Removed ticket from search index', { id });
|
|
19
|
+
}
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const newImage = record.dynamodb?.NewImage;
|
|
24
|
+
if (!newImage) {
|
|
25
|
+
logger.warn('Stream record missing NewImage', { record });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ticket = unmarshall(newImage as any);
|
|
30
|
+
const document = mapTicketToDocument(ticket);
|
|
31
|
+
|
|
32
|
+
await index.addDocuments([document], { primaryKey: 'id' });
|
|
33
|
+
logger.info('Upserted ticket into search index', { id: document.id });
|
|
34
|
+
} catch (error) {
|
|
35
|
+
logger.error('Failed to process ticket stream record for search', {
|
|
36
|
+
error,
|
|
37
|
+
eventName: record.eventName,
|
|
38
|
+
keys: record.dynamodb?.Keys,
|
|
39
|
+
});
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|