@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,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
+ };