@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,387 @@
1
+ import 'dotenv/config';
2
+ import {
3
+ ApiGatewayV1Api,
4
+ Bucket,
5
+ Cron,
6
+ Function,
7
+ Queue,
8
+ Table,
9
+ type StackContext,
10
+ } from 'sst/constructs';
11
+ import { Duration } from 'aws-cdk-lib';
12
+ import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
13
+ import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
14
+
15
+ export function SupportStack({ stack }: StackContext) {
16
+ const domainName = process.env.API_DOMAIN_NAME! || 'xcelsior.co';
17
+ const apiSubdomain =
18
+ stack.stage === 'production'
19
+ ? `support.api.${domainName}`
20
+ : `${stack.stage}-support.api.${domainName}`;
21
+
22
+ const ticketSearchIndexName =
23
+ process.env.TICKETS_SEARCH_INDEX ?? `${stack.stage}-support-tickets`;
24
+
25
+ const supportConfigTable = new Table(stack, 'support-config', {
26
+ fields: {
27
+ id: 'string',
28
+ updatedAt: 'string',
29
+ },
30
+ primaryIndex: { partitionKey: 'id' },
31
+ });
32
+
33
+ const categoriesTable = new Table(stack, 'support-categories', {
34
+ fields: {
35
+ id: 'string',
36
+ name: 'string',
37
+ createdAt: 'string',
38
+ },
39
+ primaryIndex: { partitionKey: 'id' },
40
+ });
41
+
42
+ const teamsTable = new Table(stack, 'support-teams', {
43
+ fields: {
44
+ id: 'string',
45
+ name: 'string',
46
+ createdAt: 'string',
47
+ },
48
+ primaryIndex: { partitionKey: 'id' },
49
+ });
50
+
51
+ const ticketsTable = new Table(stack, 'support-tickets', {
52
+ fields: {
53
+ id: 'string',
54
+ ticketNumber: 'number',
55
+ categoryId: 'string',
56
+ teamId: 'string',
57
+ subject: 'string',
58
+ status: 'string',
59
+ priority: 'string',
60
+ assigneeUserId: 'string',
61
+ requesterEmail: 'string',
62
+ reminderAt: 'string',
63
+ createdAt: 'string',
64
+ },
65
+ primaryIndex: { partitionKey: 'id' },
66
+ globalIndexes: {
67
+ StatusIndex: { partitionKey: 'status', sortKey: 'createdAt' },
68
+ ReminderIndex: { partitionKey: 'priority', sortKey: 'reminderAt' },
69
+ },
70
+ stream: 'new_and_old_images',
71
+ });
72
+
73
+ const ticketEventsTable = new Table(stack, 'support-ticket-events', {
74
+ fields: {
75
+ id: 'string',
76
+ ticketId: 'string',
77
+ createdAt: 'string',
78
+ },
79
+ primaryIndex: { partitionKey: 'id' },
80
+ globalIndexes: {
81
+ TicketIndex: { partitionKey: 'ticketId', sortKey: 'createdAt' },
82
+ },
83
+ });
84
+
85
+ const attachmentsBucket = new Bucket(stack, 'support-attachments', {
86
+ cors: [
87
+ {
88
+ allowedOrigins: ['*'],
89
+ allowedHeaders: ['*'],
90
+ allowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
91
+ maxAge: '1 day',
92
+ },
93
+ ],
94
+ });
95
+
96
+ // Create CloudFront distribution for serving attachments
97
+ const attachmentsDistribution = new cloudfront.Distribution(
98
+ stack,
99
+ 'support-attachments-distribution',
100
+ {
101
+ defaultBehavior: {
102
+ origin: new origins.S3Origin(attachmentsBucket.cdk.bucket),
103
+ viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
104
+ cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
105
+ allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
106
+ cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
107
+ },
108
+ comment: `CloudFront distribution for support attachments - ${stack.stage}`,
109
+ }
110
+ );
111
+
112
+ const baseFunctionEnv = {
113
+ MONITORING_PROVIDER: process.env.MONITORING_PROVIDER ?? '',
114
+ SENTRY_DSN: process.env.SENTRY_DSN ?? '',
115
+ ROLLBAR_ACCESS_TOKEN: process.env.ROLLBAR_ACCESS_TOKEN ?? '',
116
+ POWERTOOLS_SERVICE_NAME: '@xcelsior/support',
117
+ POWERTOOLS_METRICS_NAMESPACE: 'ExcelsiorSupport',
118
+ LOGTAIL_TOKEN: process.env.LOGTAIL_TOKEN ?? '',
119
+ LOGTAIL_HTTP_API_URL: process.env.LOGTAIL_HTTP_API_URL ?? '',
120
+ POWERTOOLS_LOGGER_LOG_LEVEL: 'INFO',
121
+ POWERTOOLS_LOGGER_LOG_EVENT: 'true',
122
+ APP_ENV: stack.stage,
123
+ POWERTOOLS_DEV: stack.stage === 'dev' ? 'true' : '',
124
+ NODE_ENV: stack.stage === 'dev' ? 'development' : 'production',
125
+ AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
126
+ MEILISEARCH_HOST: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
127
+ MEILISEARCH_API_KEY: process.env.MEILISEARCH_API_KEY ?? 'meili_master_key',
128
+ TICKETS_SEARCH_INDEX: ticketSearchIndexName,
129
+ CLOUDFRONT_DOMAIN: attachmentsDistribution.distributionDomainName,
130
+ // Email configuration (optional - emails will be skipped if not configured)
131
+ EMAIL_TRANSPORT: process.env.EMAIL_TRANSPORT ?? '',
132
+ EMAIL_SENDER: process.env.EMAIL_SENDER ?? '',
133
+ EMAIL_SOURCE_ARN: process.env.EMAIL_SOURCE_ARN ?? '',
134
+ SMTP_HOST: process.env.SMTP_HOST ?? '',
135
+ SMTP_PORT: process.env.SMTP_PORT ?? '',
136
+ SMTP_USER: process.env.SMTP_USER ?? '',
137
+ SMTP_PASSWORD: process.env.SMTP_PASSWORD ?? '',
138
+ APP_URL: process.env.APP_URL ?? '',
139
+ };
140
+
141
+ const reminderQueue = new Queue(stack, 'support-reminder-queue', {
142
+ cdk: {
143
+ queue: {
144
+ visibilityTimeout: Duration.minutes(1),
145
+ retentionPeriod: Duration.days(14),
146
+ },
147
+ },
148
+ });
149
+
150
+ const notificationWorker = new Function(stack, 'support-notification-worker', {
151
+ handler: 'packages/functions/workers/notificationWorker.handler',
152
+ bind: [supportConfigTable, ticketsTable, ticketEventsTable, reminderQueue],
153
+ functionName: `${stack.stage}-support-notification-worker`,
154
+ environment: {
155
+ ...baseFunctionEnv,
156
+ },
157
+ nodejs: {
158
+ esbuild: { minify: true },
159
+ },
160
+ runtime: 'nodejs18.x',
161
+ tracing: 'active',
162
+ });
163
+
164
+ reminderQueue.addConsumer(stack, {
165
+ cdk: {
166
+ eventSource: {
167
+ batchSize: 5,
168
+ maxConcurrency: 4,
169
+ },
170
+ },
171
+ function: notificationWorker,
172
+ });
173
+
174
+ // Cron job to scan for tickets needing reminders (runs every hour)
175
+ new Cron(stack, 'support-reminder-scanner', {
176
+ schedule: 'rate(1 hour)',
177
+ job: {
178
+ function: {
179
+ handler: 'packages/functions/workers/reminderScanner.handler',
180
+ bind: [supportConfigTable, ticketsTable, reminderQueue],
181
+ functionName: `${stack.stage}-support-reminder-scanner`,
182
+ environment: baseFunctionEnv,
183
+ timeout: '5 minutes',
184
+ nodejs: {
185
+ esbuild: { minify: true },
186
+ },
187
+ runtime: 'nodejs18.x',
188
+ tracing: 'active',
189
+ },
190
+ },
191
+ });
192
+
193
+ ticketsTable.addConsumers(stack, {
194
+ 'ticket-search-sync': {
195
+ function: {
196
+ handler: 'packages/functions/tickets/searchSync.handler',
197
+ bind: [
198
+ supportConfigTable,
199
+ categoriesTable,
200
+ teamsTable,
201
+ ticketsTable,
202
+ ticketEventsTable,
203
+ attachmentsBucket,
204
+ ],
205
+ functionName: `${stack.stage}-support-ticket-search-sync`,
206
+ environment: baseFunctionEnv,
207
+ nodejs: {
208
+ esbuild: { minify: true },
209
+ },
210
+ runtime: 'nodejs18.x',
211
+ tracing: 'active',
212
+ },
213
+ },
214
+ });
215
+
216
+ const api = new ApiGatewayV1Api(stack, 'support-api', {
217
+ customDomain: {
218
+ domainName: apiSubdomain,
219
+ hostedZone: domainName,
220
+ },
221
+ defaults: {
222
+ function: {
223
+ bind: [
224
+ supportConfigTable,
225
+ categoriesTable,
226
+ teamsTable,
227
+ ticketsTable,
228
+ ticketEventsTable,
229
+ attachmentsBucket,
230
+ reminderQueue,
231
+ ],
232
+ environment: {
233
+ ...baseFunctionEnv,
234
+ },
235
+ nodejs: {
236
+ esbuild: { minify: true },
237
+ },
238
+ runtime: 'nodejs18.x',
239
+ tracing: 'active',
240
+ },
241
+ },
242
+ cors: true,
243
+ routes: {
244
+ 'GET /api/admin/support/config': {
245
+ function: {
246
+ handler: 'packages/functions/config/get.handler',
247
+ functionName: `${stack.stage}-support-get-config`,
248
+ },
249
+ cdk: { method: { apiKeyRequired: true } },
250
+ },
251
+ 'PUT /api/admin/support/config': {
252
+ function: {
253
+ handler: 'packages/functions/config/update.handler',
254
+ functionName: `${stack.stage}-support-update-config`,
255
+ },
256
+ cdk: { method: { apiKeyRequired: true } },
257
+ },
258
+ 'GET /api/admin/categories': {
259
+ function: {
260
+ handler: 'packages/functions/categories/list.handler',
261
+ functionName: `${stack.stage}-support-list-categories`,
262
+ },
263
+ cdk: { method: { apiKeyRequired: true } },
264
+ },
265
+ 'POST /api/admin/categories': {
266
+ function: {
267
+ handler: 'packages/functions/categories/create.handler',
268
+ functionName: `${stack.stage}-support-create-category`,
269
+ },
270
+ cdk: { method: { apiKeyRequired: true } },
271
+ },
272
+ 'PATCH /api/admin/categories/{id}': {
273
+ function: {
274
+ handler: 'packages/functions/categories/update.handler',
275
+ functionName: `${stack.stage}-support-update-category`,
276
+ },
277
+ cdk: { method: { apiKeyRequired: true } },
278
+ },
279
+ 'POST /api/admin/categories/{id}/archive': {
280
+ function: {
281
+ handler: 'packages/functions/categories/archive.handler',
282
+ functionName: `${stack.stage}-support-archive-category`,
283
+ },
284
+ cdk: { method: { apiKeyRequired: true } },
285
+ },
286
+ 'GET /api/admin/teams': {
287
+ function: {
288
+ handler: 'packages/functions/teams/list.handler',
289
+ functionName: `${stack.stage}-support-list-teams`,
290
+ },
291
+ cdk: { method: { apiKeyRequired: true } },
292
+ },
293
+ 'POST /api/admin/teams': {
294
+ function: {
295
+ handler: 'packages/functions/teams/create.handler',
296
+ functionName: `${stack.stage}-support-create-team`,
297
+ },
298
+ cdk: { method: { apiKeyRequired: true } },
299
+ },
300
+ 'POST /api/admin/teams/{id}/members': {
301
+ function: {
302
+ handler: 'packages/functions/teams/addMember.handler',
303
+ functionName: `${stack.stage}-support-add-team-member`,
304
+ },
305
+ cdk: { method: { apiKeyRequired: true } },
306
+ },
307
+ 'DELETE /api/admin/teams/{id}/members/{userId}': {
308
+ function: {
309
+ handler: 'packages/functions/teams/removeMember.handler',
310
+ functionName: `${stack.stage}-support-remove-team-member`,
311
+ },
312
+ cdk: { method: { apiKeyRequired: true } },
313
+ },
314
+ 'POST /api/tickets': {
315
+ function: {
316
+ handler: 'packages/functions/tickets/create.handler',
317
+ functionName: `${stack.stage}-support-create-ticket`,
318
+ },
319
+ cdk: { method: { apiKeyRequired: true } },
320
+ },
321
+ 'GET /api/tickets/{id}': {
322
+ function: {
323
+ handler: 'packages/functions/tickets/get.handler',
324
+ functionName: `${stack.stage}-support-get-ticket`,
325
+ },
326
+ cdk: { method: { apiKeyRequired: true } },
327
+ },
328
+ 'PATCH /api/tickets/{id}': {
329
+ function: {
330
+ handler: 'packages/functions/tickets/update.handler',
331
+ functionName: `${stack.stage}-support-update-ticket`,
332
+ },
333
+ cdk: { method: { apiKeyRequired: true } },
334
+ },
335
+ 'GET /api/tickets': {
336
+ function: {
337
+ handler: 'packages/functions/tickets/list.handler',
338
+ functionName: `${stack.stage}-support-list-tickets`,
339
+ },
340
+ cdk: { method: { apiKeyRequired: true } },
341
+ },
342
+ 'GET /api/stats/tickets': {
343
+ function: {
344
+ handler: 'packages/functions/tickets/statusFacets.handler',
345
+ functionName: `${stack.stage}-support-ticket-status-facets`,
346
+ },
347
+ cdk: { method: { apiKeyRequired: true } },
348
+ },
349
+ 'GET /api/tickets/{id}/events': {
350
+ function: {
351
+ handler: 'packages/functions/events/list.handler',
352
+ functionName: `${stack.stage}-support-list-ticket-events`,
353
+ },
354
+ cdk: { method: { apiKeyRequired: true } },
355
+ },
356
+ 'POST /api/attachments/upload-url': {
357
+ function: {
358
+ handler: 'packages/functions/attachments/generateUploadUrl.handler',
359
+ functionName: `${stack.stage}-support-generate-attachment-upload-url`,
360
+ },
361
+ cdk: { method: { apiKeyRequired: true } },
362
+ },
363
+ },
364
+ });
365
+
366
+ const apiKey = api.cdk.restApi.addApiKey('support-api-key', {
367
+ apiKeyName: `support-api-key-${stack.stage}`,
368
+ });
369
+
370
+ const usagePlan = api.cdk.restApi.addUsagePlan('support-api-usage', {
371
+ name: `support-usage-plan-${stack.stage}`,
372
+ apiStages: [
373
+ {
374
+ api: api.cdk.restApi,
375
+ stage: api.cdk.restApi.deploymentStage,
376
+ },
377
+ ],
378
+ });
379
+
380
+ usagePlan.addApiKey(apiKey);
381
+
382
+ stack.addOutputs({
383
+ ApiEndpoint: api.url,
384
+ AttachmentsBucket: attachmentsBucket.bucketName,
385
+ CloudFrontDomain: attachmentsDistribution.distributionDomainName,
386
+ });
387
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../packages/config/typescript/node.json",
3
+ "compilerOptions": {
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "noEmit": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "types": ["@types/aws-lambda"]
10
+ },
11
+ "include": ["./packages/**/*.ts", "./stacks/**/*.ts", "./sst.config.ts"]
12
+ }