@xcelsior/support-api 0.1.1 → 0.1.3

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.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "packages/**",
@@ -26,10 +26,10 @@
26
26
  "@tsconfig/node18": "^18.2.2",
27
27
  "@types/node": "^20.11.16",
28
28
  "@xcelsior/aws": "1.0.3",
29
+ "@xcelsior/support-client": "1.0.1",
29
30
  "@xcelsior/lambda-http": "1.0.5",
30
31
  "@xcelsior/monitoring": "1.0.4",
31
- "@xcelsior/email": "1.0.2",
32
- "@xcelsior/support-client": "1.0.0"
32
+ "@xcelsior/email": "1.0.2"
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "sst dev --stage dev --mode basic",
@@ -0,0 +1,243 @@
1
+ import type { SQSEvent } from 'aws-lambda';
2
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
3
+ import { SqsService } from '@xcelsior/aws/src/sqs';
4
+ import {
5
+ logger,
6
+ TICKETS_TABLE,
7
+ TICKET_EVENTS_TABLE,
8
+ SUPPORT_CONFIG_TABLE,
9
+ REMINDER_QUEUE,
10
+ } from '../config';
11
+ import {
12
+ sendReminderEmail,
13
+ sendAssignmentEmail,
14
+ isEmailConfigured,
15
+ } from '../services/emailService';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ interface AssignmentMessage {
19
+ type: 'assignment';
20
+ ticketId: string;
21
+ assigneeEmail: string;
22
+ ticketNumber: number;
23
+ subject: string;
24
+ priority?: string | null;
25
+ requesterName: string;
26
+ requesterEmail: string;
27
+ }
28
+
29
+ interface ReminderMessage {
30
+ type: 'reminder';
31
+ ticketId: string;
32
+ }
33
+
34
+ type NotificationMessage = AssignmentMessage | ReminderMessage;
35
+
36
+ interface Ticket {
37
+ id: string;
38
+ ticketNumber: number;
39
+ subject: string;
40
+ status: string;
41
+ priority?: string | null;
42
+ assigneeUserId?: string | null;
43
+ assigneeEmail?: string | null;
44
+ requesterName: string;
45
+ requesterEmail: string;
46
+ reminderAt?: string | null;
47
+ reminderSentAt?: string | null;
48
+ createdAt: string;
49
+ }
50
+
51
+ const processAssignment = async (message: AssignmentMessage): Promise<void> => {
52
+ // Check if assignment notifications are enabled
53
+ const config = await DynamoDBService.getItem<{
54
+ id: string;
55
+ notification?: { emailOnAssignment?: boolean };
56
+ }>(
57
+ {
58
+ tableName: SUPPORT_CONFIG_TABLE,
59
+ key: 'support-config',
60
+ },
61
+ logger
62
+ );
63
+
64
+ if (config?.notification?.emailOnAssignment === false) {
65
+ logger.info('Assignment emails disabled in config, skipping', {
66
+ ticketId: message.ticketId,
67
+ });
68
+ return;
69
+ }
70
+
71
+ await sendAssignmentEmail({
72
+ to: message.assigneeEmail,
73
+ ticketNumber: message.ticketNumber,
74
+ ticketId: message.ticketId,
75
+ subject: message.subject,
76
+ priority: message.priority,
77
+ requesterName: message.requesterName,
78
+ requesterEmail: message.requesterEmail,
79
+ });
80
+
81
+ // Log the assignment notification event
82
+ await DynamoDBService.putItem(
83
+ {
84
+ tableName: TICKET_EVENTS_TABLE,
85
+ item: {
86
+ id: randomUUID(),
87
+ ticketId: message.ticketId,
88
+ type: 'assignment_email_sent',
89
+ payload: {
90
+ assigneeEmail: message.assigneeEmail,
91
+ },
92
+ createdAt: new Date().toISOString(),
93
+ },
94
+ },
95
+ logger
96
+ );
97
+
98
+ logger.info('Assignment notification sent', {
99
+ ticketId: message.ticketId,
100
+ assigneeEmail: message.assigneeEmail,
101
+ });
102
+ };
103
+
104
+ const processReminder = async (message: ReminderMessage): Promise<void> => {
105
+ const ticket = await DynamoDBService.getItem<Ticket>(
106
+ {
107
+ tableName: TICKETS_TABLE,
108
+ key: message.ticketId,
109
+ },
110
+ logger
111
+ );
112
+
113
+ if (!ticket) {
114
+ logger.warn('Ticket not found for reminder', { ticketId: message.ticketId });
115
+ return;
116
+ }
117
+
118
+ // Skip if ticket is already resolved or archived
119
+ if (['Resolved', 'Archived'].includes(ticket.status)) {
120
+ logger.info('Ticket already resolved, skipping reminder', {
121
+ ticketId: ticket.id,
122
+ status: ticket.status,
123
+ });
124
+ return;
125
+ }
126
+
127
+ // Skip if no assignee email
128
+ if (!ticket.assigneeEmail) {
129
+ logger.info('No assignee email, skipping reminder', { ticketId: ticket.id });
130
+ return;
131
+ }
132
+
133
+ // Skip if reminder already sent for this reminderAt
134
+ if (ticket.reminderSentAt && ticket.reminderAt) {
135
+ const sentAt = new Date(ticket.reminderSentAt);
136
+ const reminderAt = new Date(ticket.reminderAt);
137
+ if (sentAt >= reminderAt) {
138
+ logger.info('Reminder already sent for this period', {
139
+ ticketId: ticket.id,
140
+ reminderSentAt: ticket.reminderSentAt,
141
+ reminderAt: ticket.reminderAt,
142
+ });
143
+ return;
144
+ }
145
+ }
146
+
147
+ // Check if reminder notifications are enabled
148
+ const config = await DynamoDBService.getItem<{
149
+ id: string;
150
+ notification?: { emailOnReminder?: boolean };
151
+ }>(
152
+ {
153
+ tableName: SUPPORT_CONFIG_TABLE,
154
+ key: 'support-config',
155
+ },
156
+ logger
157
+ );
158
+
159
+ if (config?.notification?.emailOnReminder === false) {
160
+ logger.info('Reminder emails disabled in config, skipping', { ticketId: ticket.id });
161
+ return;
162
+ }
163
+
164
+ // Send the reminder email
165
+ await sendReminderEmail({
166
+ to: ticket.assigneeEmail,
167
+ ticketNumber: ticket.ticketNumber,
168
+ ticketId: ticket.id,
169
+ subject: ticket.subject,
170
+ priority: ticket.priority,
171
+ status: ticket.status,
172
+ requesterName: ticket.requesterName,
173
+ requesterEmail: ticket.requesterEmail,
174
+ reminderAt: ticket.reminderAt!,
175
+ createdAt: ticket.createdAt,
176
+ });
177
+
178
+ // Update ticket with reminderSentAt
179
+ const now = new Date().toISOString();
180
+ await DynamoDBService.updateItem(
181
+ {
182
+ tableName: TICKETS_TABLE,
183
+ key: { id: ticket.id },
184
+ updateExpression: 'SET reminderSentAt = :reminderSentAt',
185
+ expressionAttributeValues: {
186
+ ':reminderSentAt': now,
187
+ },
188
+ },
189
+ logger
190
+ );
191
+
192
+ // Log the reminder event
193
+ await DynamoDBService.putItem(
194
+ {
195
+ tableName: TICKET_EVENTS_TABLE,
196
+ item: {
197
+ id: randomUUID(),
198
+ ticketId: ticket.id,
199
+ type: 'reminder_sent',
200
+ payload: {
201
+ assigneeEmail: ticket.assigneeEmail,
202
+ reminderAt: ticket.reminderAt,
203
+ },
204
+ createdAt: now,
205
+ },
206
+ },
207
+ logger
208
+ );
209
+
210
+ logger.info('Reminder processed successfully', {
211
+ ticketId: ticket.id,
212
+ ticketNumber: ticket.ticketNumber,
213
+ assigneeEmail: ticket.assigneeEmail,
214
+ });
215
+ };
216
+
217
+ const processMessage = async (message: NotificationMessage): Promise<void> => {
218
+ switch (message.type) {
219
+ case 'assignment':
220
+ await processAssignment(message);
221
+ break;
222
+ case 'reminder':
223
+ await processReminder(message);
224
+ break;
225
+ default:
226
+ logger.warn('Unknown message type', { message });
227
+ }
228
+ };
229
+
230
+ export const handler = async (event: SQSEvent) => {
231
+ if (!isEmailConfigured()) {
232
+ logger.info('Email not configured, skipping notification processing', {
233
+ recordCount: event.Records.length,
234
+ });
235
+ return;
236
+ }
237
+
238
+ logger.info('Processing notification messages', {
239
+ recordCount: event.Records.length,
240
+ });
241
+
242
+ await SqsService.handleMessages(event, REMINDER_QUEUE, processMessage);
243
+ };
@@ -0,0 +1,130 @@
1
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
2
+ import { SqsService } from '@xcelsior/aws/src/sqs';
3
+ import { logger, TICKETS_TABLE, REMINDER_QUEUE, SUPPORT_CONFIG_TABLE } from '../config';
4
+ import { isEmailConfigured } from '../services/emailService';
5
+
6
+ interface Ticket {
7
+ id: string;
8
+ status: string;
9
+ priority?: string | null;
10
+ assigneeEmail?: string | null;
11
+ reminderAt?: string | null;
12
+ reminderSentAt?: string | null;
13
+ }
14
+
15
+ interface ReminderMessage {
16
+ ticketId: string;
17
+ type: 'reminder';
18
+ }
19
+
20
+ const PRIORITIES = ['low', 'medium', 'high', 'critical'] as const;
21
+
22
+ /**
23
+ * Scans for tickets that need reminders and queues them for processing.
24
+ * Runs on a schedule (cron) to find overdue tickets.
25
+ */
26
+ export const handler = async () => {
27
+ // Check if email is configured - skip if not
28
+ if (!isEmailConfigured()) {
29
+ logger.info('Email not configured, skipping reminder scan');
30
+ return { processed: 0, queued: 0 };
31
+ }
32
+
33
+ // Check if reminder notifications are enabled in config
34
+ const config = await DynamoDBService.getItem<{
35
+ id: string;
36
+ notification?: { emailOnReminder?: boolean };
37
+ }>(
38
+ {
39
+ tableName: SUPPORT_CONFIG_TABLE,
40
+ key: 'support-config',
41
+ },
42
+ logger
43
+ );
44
+
45
+ if (config?.notification?.emailOnReminder === false) {
46
+ logger.info('Reminder notifications disabled in config');
47
+ return { processed: 0, queued: 0 };
48
+ }
49
+
50
+ const now = new Date().toISOString();
51
+ let totalProcessed = 0;
52
+ const messagesToQueue: ReminderMessage[] = [];
53
+
54
+ // Query each priority using the ReminderIndex GSI
55
+ // The GSI has priority as partition key and reminderAt as sort key
56
+ for (const priority of PRIORITIES) {
57
+ try {
58
+ // Query tickets where reminderAt <= now for this priority
59
+ const result = await DynamoDBService.queryItems<Ticket>(
60
+ {
61
+ tableName: TICKETS_TABLE,
62
+ indexName: 'ReminderIndex',
63
+ keyConditionExpression: 'priority = :priority AND reminderAt <= :now',
64
+ expressionAttributeValues: {
65
+ ':priority': priority,
66
+ ':now': now,
67
+ },
68
+ },
69
+ logger
70
+ );
71
+
72
+ const tickets = result.items ?? [];
73
+ totalProcessed += tickets.length;
74
+
75
+ for (const ticket of tickets) {
76
+ // Skip if no assignee email
77
+ if (!ticket.assigneeEmail) {
78
+ continue;
79
+ }
80
+
81
+ // Skip if ticket is resolved or archived
82
+ if (['Resolved', 'Archived'].includes(ticket.status)) {
83
+ continue;
84
+ }
85
+
86
+ // Skip if reminder was already sent for this reminderAt
87
+ if (ticket.reminderSentAt && ticket.reminderAt) {
88
+ const sentAt = new Date(ticket.reminderSentAt);
89
+ const reminderAt = new Date(ticket.reminderAt);
90
+ if (sentAt >= reminderAt) {
91
+ continue;
92
+ }
93
+ }
94
+
95
+ messagesToQueue.push({
96
+ ticketId: ticket.id,
97
+ type: 'reminder',
98
+ });
99
+ }
100
+ } catch (error) {
101
+ logger.error('Failed to scan tickets for priority', {
102
+ error: error instanceof Error ? error.message : 'Unknown error',
103
+ priority,
104
+ });
105
+ }
106
+ }
107
+
108
+ // Send all messages in batches
109
+ if (messagesToQueue.length > 0) {
110
+ try {
111
+ await SqsService.sendMessageBatch({
112
+ url: REMINDER_QUEUE,
113
+ messages: messagesToQueue,
114
+ id: (msg) => `reminder-${msg.ticketId}`,
115
+ });
116
+ logger.info('Queued reminder messages', { count: messagesToQueue.length });
117
+ } catch (error) {
118
+ logger.error('Failed to queue reminder messages', {
119
+ error: error instanceof Error ? error.message : 'Unknown error',
120
+ });
121
+ }
122
+ }
123
+
124
+ logger.info('Reminder scan complete', {
125
+ totalProcessed,
126
+ totalQueued: messagesToQueue.length,
127
+ });
128
+
129
+ return { processed: totalProcessed, queued: messagesToQueue.length };
130
+ };
@@ -1,11 +0,0 @@
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);