@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,6 @@
1
+ {
2
+ "hosted-zone:account=192933325589:domainName=xcelsior.co:region=ap-southeast-2": {
3
+ "Id": "/hostedzone/Z08595403217R035ZUO3M",
4
+ "Name": "xcelsior.co."
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@xcelsior/support-api",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "files": [
6
+ "packages/**",
7
+ "stacks/**",
8
+ "docs/**",
9
+ "*.json",
10
+ "sst.config.ts"
11
+ ],
12
+ "dependencies": {
13
+ "@aws-sdk/client-dynamodb": "^3.888.0",
14
+ "@aws-sdk/client-s3": "^3.888.0",
15
+ "@aws-sdk/client-sqs": "^3.888.0",
16
+ "@aws-sdk/lib-dynamodb": "^3.888.0",
17
+ "@aws-sdk/util-dynamodb": "^3.888.0",
18
+ "meilisearch": "^0.55.0",
19
+ "sst": "^2.40.3",
20
+ "zod": "^3.22.4",
21
+ "@types/aws-lambda": "^8.10.152",
22
+ "aws-cdk-lib": "2.201.0",
23
+ "constructs": "10.3.0",
24
+ "dotenv": "^17.2.1",
25
+ "typescript": "^5.3.3",
26
+ "@tsconfig/node18": "^18.2.2",
27
+ "@types/node": "^20.11.16",
28
+ "@xcelsior/aws": "1.0.3",
29
+ "@xcelsior/lambda-http": "1.0.5",
30
+ "@xcelsior/monitoring": "1.0.4",
31
+ "@xcelsior/email": "1.0.2",
32
+ "@xcelsior/support-client": "1.0.0"
33
+ },
34
+ "scripts": {
35
+ "dev": "sst dev --stage dev --mode basic",
36
+ "build": "sst build",
37
+ "deploy": "sst deploy",
38
+ "remove": "sst remove",
39
+ "console": "sst console",
40
+ "typecheck": "tsc --noEmit"
41
+ }
42
+ }
@@ -0,0 +1,55 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import {
3
+ ATTACHMENTS_BUCKET,
4
+ CLOUDFRONT_DOMAIN,
5
+ createResponse,
6
+ logger,
7
+ middlewareConfig,
8
+ } from '../config';
9
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
10
+ import { ZodError } from 'zod';
11
+ import { randomUUID } from 'node:crypto';
12
+ import { S3Service } from '@xcelsior/aws/src/s3';
13
+ import { attachmentUploadRequestSchema } from '../types';
14
+
15
+ const generateUploadUrl = async (event: APIGatewayProxyEventV2) => {
16
+ let parsedBody;
17
+ try {
18
+ parsedBody = attachmentUploadRequestSchema.parse(event.body);
19
+ } catch (error) {
20
+ if (error instanceof ZodError) {
21
+ throw new ValidationError('Invalid request data', error.errors);
22
+ }
23
+ throw error;
24
+ }
25
+
26
+ const { fileName, contentType } = parsedBody;
27
+
28
+ const fileExtension = fileName.split('.').pop()?.toLowerCase() || 'bin';
29
+ const key = `attachments/${randomUUID()}.${fileExtension}`;
30
+
31
+ logger.info('Generating presigned upload URL for attachment', {
32
+ fileName,
33
+ contentType,
34
+ key,
35
+ });
36
+
37
+ const uploadUrl = await S3Service.generatePresignedUploadUrl(
38
+ ATTACHMENTS_BUCKET,
39
+ key,
40
+ contentType,
41
+ 300
42
+ );
43
+
44
+ // The final public URL where the attachment will be accessible via CloudFront
45
+ const attachmentUrl = `https://${CLOUDFRONT_DOMAIN}/${key}`;
46
+
47
+ return createResponse({
48
+ uploadUrl,
49
+ attachmentUrl,
50
+ key,
51
+ expiresIn: 300,
52
+ });
53
+ };
54
+
55
+ export const handler = middyfy(generateUploadUrl, middlewareConfig);
@@ -0,0 +1,57 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, CATEGORIES_TABLE } from '../config';
3
+ import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
4
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
5
+
6
+ const archiveCategory = async (event: APIGatewayProxyEventV2) => {
7
+ const id = event.pathParameters?.id;
8
+ if (!id) {
9
+ throw new ValidationError('Category id is required', []);
10
+ }
11
+
12
+ const existing = await DynamoDBService.getItem<any>(
13
+ {
14
+ tableName: CATEGORIES_TABLE,
15
+ key: id,
16
+ },
17
+ logger
18
+ );
19
+
20
+ if (!existing) {
21
+ throw new NotFoundError('Category not found');
22
+ }
23
+
24
+ const archivedAt = new Date().toISOString();
25
+
26
+ const updated = await DynamoDBService.updateItem(
27
+ {
28
+ tableName: CATEGORIES_TABLE,
29
+ key: { id },
30
+ updateExpression:
31
+ 'SET archivedAt = :archivedAt, isActive = :isActive, updatedAt = :updatedAt',
32
+ expressionAttributeValues: {
33
+ ':archivedAt': archivedAt,
34
+ ':isActive': false,
35
+ ':updatedAt': archivedAt,
36
+ },
37
+ },
38
+ logger
39
+ );
40
+
41
+ logger.info('Archive category', { id });
42
+
43
+ const safeUpdated =
44
+ (updated as any) ??
45
+ ({
46
+ id,
47
+ archivedAt,
48
+ isActive: false,
49
+ } as const);
50
+
51
+ return createResponse({
52
+ ...safeUpdated,
53
+ isActive: false,
54
+ });
55
+ };
56
+
57
+ export const handler = middyfy(archiveCategory, middlewareConfig);
@@ -0,0 +1,44 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, CATEGORIES_TABLE } from '../config';
3
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
4
+ import { categoryCreateSchema } from '../types';
5
+ import { z } from 'zod';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
8
+
9
+ const createCategory = async (event: APIGatewayProxyEventV2) => {
10
+ let payload;
11
+ try {
12
+ payload = categoryCreateSchema.parse(event.body);
13
+ } catch (error) {
14
+ if (error instanceof z.ZodError) {
15
+ throw new ValidationError('Invalid category payload', error.errors);
16
+ }
17
+ throw error;
18
+ }
19
+
20
+ const now = new Date().toISOString();
21
+ const category = {
22
+ id: randomUUID(),
23
+ name: payload.name,
24
+ isActive: true,
25
+ createdAt: now,
26
+ updatedAt: now,
27
+ };
28
+
29
+ logger.info('Create category', { categoryId: category.id });
30
+
31
+ await DynamoDBService.putItem(
32
+ {
33
+ tableName: CATEGORIES_TABLE,
34
+ item: category,
35
+ },
36
+ logger
37
+ );
38
+
39
+ return createResponse({
40
+ ...category,
41
+ });
42
+ };
43
+
44
+ export const handler = middyfy(createCategory, middlewareConfig);
@@ -0,0 +1,29 @@
1
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
2
+ import { middyfy } from '@xcelsior/lambda-http';
3
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
4
+ import { CATEGORIES_TABLE, createResponse, logger, middlewareConfig } from '../config';
5
+
6
+ const listCategories = async (event: APIGatewayProxyEventV2) => {
7
+ logger.info('List categories');
8
+
9
+ const result = await DynamoDBService.scanItems<any>(
10
+ {
11
+ tableName: CATEGORIES_TABLE,
12
+ },
13
+ logger
14
+ );
15
+
16
+ const items = result.items.map((item) => ({
17
+ ...item,
18
+ isActive: !item.archivedAt,
19
+ }));
20
+
21
+ return createResponse({
22
+ items,
23
+ nextToken: result.nextToken
24
+ ? Buffer.from(JSON.stringify(result.nextToken)).toString('base64')
25
+ : undefined,
26
+ });
27
+ };
28
+
29
+ export const handler = middyfy(listCategories, middlewareConfig);
@@ -0,0 +1,73 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, CATEGORIES_TABLE } from '../config';
3
+ import { middyfy, NotFoundError, ValidationError } from '@xcelsior/lambda-http';
4
+ import { categoryUpdateSchema } from '../types';
5
+ import { z } from 'zod';
6
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
7
+
8
+ const updateCategory = async (event: APIGatewayProxyEventV2) => {
9
+ let payload;
10
+ try {
11
+ payload = categoryUpdateSchema.parse(event.body);
12
+ } catch (error) {
13
+ if (error instanceof z.ZodError) {
14
+ throw new ValidationError('Invalid category update payload', error.errors);
15
+ }
16
+ throw error;
17
+ }
18
+
19
+ const id = event.pathParameters?.id;
20
+ if (!id) {
21
+ throw new ValidationError('Category id is required', []);
22
+ }
23
+
24
+ const existing = await DynamoDBService.getItem<any>(
25
+ {
26
+ tableName: CATEGORIES_TABLE,
27
+ key: id,
28
+ },
29
+ logger
30
+ );
31
+
32
+ if (!existing) {
33
+ throw new NotFoundError('Category not found');
34
+ }
35
+
36
+ const updates: string[] = [];
37
+ const values: Record<string, any> = {};
38
+ const names: Record<string, string> = {};
39
+
40
+ if (payload.name) {
41
+ updates.push('#name = :name');
42
+ values[':name'] = payload.name;
43
+ names['#name'] = 'name';
44
+ }
45
+
46
+ if (payload.isActive !== undefined) {
47
+ updates.push('isActive = :isActive');
48
+ values[':isActive'] = payload.isActive;
49
+ }
50
+
51
+ updates.push('updatedAt = :updatedAt');
52
+ values[':updatedAt'] = new Date().toISOString();
53
+
54
+ const updated = await DynamoDBService.updateItem(
55
+ {
56
+ tableName: CATEGORIES_TABLE,
57
+ key: { id },
58
+ updateExpression: `SET ${updates.join(', ')}`,
59
+ expressionAttributeValues: values,
60
+ expressionAttributeNames: Object.keys(names).length ? names : undefined,
61
+ },
62
+ logger
63
+ );
64
+
65
+ const safeUpdated = (updated as any) ?? { id, isActive: payload.isActive ?? existing.isActive };
66
+
67
+ return createResponse({
68
+ ...safeUpdated,
69
+ isActive: safeUpdated?.isActive !== false,
70
+ });
71
+ };
72
+
73
+ export const handler = middyfy(updateCategory, middlewareConfig);
@@ -0,0 +1,40 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, SUPPORT_CONFIG_TABLE } from '../config';
3
+ import { middyfy } from '@xcelsior/lambda-http';
4
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
5
+
6
+ const CONFIG_ID = 'support-config';
7
+
8
+ const getSupportConfig = async (_event: APIGatewayProxyEventV2) => {
9
+ logger.info('Get support config');
10
+
11
+ const existing = await DynamoDBService.getItem<{
12
+ id: string;
13
+ notification?: { emailOnAssignment?: boolean };
14
+ sla?: { low?: number; medium?: number; high?: number; critical?: number };
15
+ updatedAt?: string;
16
+ }>(
17
+ {
18
+ tableName: SUPPORT_CONFIG_TABLE,
19
+ key: CONFIG_ID,
20
+ },
21
+ logger
22
+ );
23
+
24
+ if (!existing) {
25
+ return createResponse({
26
+ id: CONFIG_ID,
27
+ notification: { emailOnAssignment: true },
28
+ sla: { low: 7, medium: 5, high: 3, critical: 1 },
29
+ });
30
+ }
31
+
32
+ return createResponse({
33
+ id: existing.id,
34
+ notification: existing.notification ?? { emailOnAssignment: true },
35
+ sla: existing.sla ?? { low: 7, medium: 5, high: 3, critical: 1 },
36
+ updatedAt: existing.updatedAt,
37
+ });
38
+ };
39
+
40
+ export const handler = middyfy(getSupportConfig, middlewareConfig);
@@ -0,0 +1,44 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, SUPPORT_CONFIG_TABLE } from '../config';
3
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
4
+ import { supportConfigSchema } from '../types';
5
+ import { z } from 'zod';
6
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
7
+
8
+ const CONFIG_ID = 'support-config';
9
+
10
+ const updateSupportConfig = async (event: APIGatewayProxyEventV2) => {
11
+ let payload;
12
+ try {
13
+ payload = supportConfigSchema.parse(event.body);
14
+ } catch (error) {
15
+ if (error instanceof z.ZodError) {
16
+ throw new ValidationError('Invalid support config', error.errors);
17
+ }
18
+ throw error;
19
+ }
20
+
21
+ logger.info('Update support config');
22
+
23
+ const now = new Date().toISOString();
24
+ const settings = {
25
+ notification: payload.notification ?? { emailOnAssignment: true },
26
+ sla: payload.sla ?? { low: 7, medium: 5, high: 3, critical: 1 },
27
+ };
28
+
29
+ await DynamoDBService.putItem(
30
+ {
31
+ tableName: SUPPORT_CONFIG_TABLE,
32
+ item: {
33
+ id: CONFIG_ID,
34
+ ...settings,
35
+ updatedAt: now,
36
+ },
37
+ },
38
+ logger
39
+ );
40
+
41
+ return createResponse({ id: CONFIG_ID, ...settings, updatedAt: now });
42
+ };
43
+
44
+ export const handler = middyfy(updateSupportConfig, middlewareConfig);
@@ -0,0 +1,40 @@
1
+ import { Table } from 'sst/node/table';
2
+ import { Bucket } from 'sst/node/bucket';
3
+ import { Queue } from 'sst/node/queue';
4
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
5
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
6
+ import { createLogger, createTracer } from '@xcelsior/lambda-http';
7
+ import type { ApiResponse } from './types';
8
+
9
+ export const SUPPORT_CONFIG_TABLE = Table['support-config'].tableName;
10
+ export const CATEGORIES_TABLE = Table['support-categories'].tableName;
11
+ export const TEAMS_TABLE = Table['support-teams'].tableName;
12
+ export const TICKETS_TABLE = Table['support-tickets'].tableName;
13
+ export const TICKET_EVENTS_TABLE = Table['support-ticket-events'].tableName;
14
+ export const ATTACHMENTS_BUCKET = Bucket['support-attachments'].bucketName;
15
+ export const REMINDER_QUEUE = Queue['support-reminder-queue'].queueUrl;
16
+ export const CLOUDFRONT_DOMAIN = process.env.CLOUDFRONT_DOMAIN!;
17
+
18
+ const client = new DynamoDBClient({});
19
+ export const dynamoDb = DynamoDBDocumentClient.from(client, {
20
+ marshallOptions: { removeUndefinedValues: true },
21
+ });
22
+
23
+ export function createResponse<T>(
24
+ data?: T,
25
+ error?: { code: string; message: string },
26
+ pagination?: { nextPageToken?: string }
27
+ ): ApiResponse<T> {
28
+ return { data, error, pagination };
29
+ }
30
+
31
+ export const logger = createLogger('@xcelsior/support');
32
+ const tracer = createTracer('@xcelsior/support');
33
+
34
+ export const middlewareConfig = {
35
+ logger,
36
+ tracer,
37
+ cors: {
38
+ origin: '*',
39
+ },
40
+ };
@@ -0,0 +1,48 @@
1
+ import type { APIGatewayProxyEventV2 } from 'aws-lambda';
2
+ import { createResponse, logger, middlewareConfig, TICKET_EVENTS_TABLE } from '../config';
3
+ import { middyfy, ValidationError } from '@xcelsior/lambda-http';
4
+ import { DynamoDBService } from '@xcelsior/aws/src/dynamodb';
5
+
6
+ const listTicketEvents = async (event: APIGatewayProxyEventV2) => {
7
+ const ticketId = event.pathParameters?.id;
8
+ if (!ticketId) {
9
+ throw new ValidationError('Ticket id is required', []);
10
+ }
11
+
12
+ const limit = event.queryStringParameters?.limit
13
+ ? Number.parseInt(event.queryStringParameters.limit, 10)
14
+ : 50;
15
+
16
+ const nextToken = event.queryStringParameters?.nextToken;
17
+
18
+ logger.info('List ticket events', { ticketId, limit });
19
+
20
+ const result = await DynamoDBService.queryItems(
21
+ {
22
+ tableName: TICKET_EVENTS_TABLE,
23
+ indexName: 'TicketIndex',
24
+ keyConditionExpression: 'ticketId = :ticketId',
25
+ expressionAttributeValues: {
26
+ ':ticketId': ticketId,
27
+ },
28
+ scanIndexForward: false, // Sort descending (newest first)
29
+ limit,
30
+ exclusiveStartKey: nextToken
31
+ ? JSON.parse(Buffer.from(nextToken, 'base64').toString())
32
+ : undefined,
33
+ },
34
+ logger
35
+ );
36
+
37
+ const events = result.items ?? [];
38
+ const lastEvaluatedKey = result.nextToken;
39
+
40
+ return createResponse({
41
+ items: events,
42
+ nextToken: lastEvaluatedKey
43
+ ? Buffer.from(JSON.stringify(lastEvaluatedKey)).toString('base64')
44
+ : undefined,
45
+ });
46
+ };
47
+
48
+ export const handler = middyfy(listTicketEvents, middlewareConfig);
@@ -0,0 +1,128 @@
1
+ import { MeiliSearch, type Index } from 'meilisearch';
2
+ import { logger } from '../config';
3
+
4
+ export type TicketSearchDocument = {
5
+ id: string;
6
+ ticketNumber?: number;
7
+ subject: string;
8
+ description: string;
9
+ status: string;
10
+ priority: string | null;
11
+ categoryId: string | null;
12
+ teamId: string | null;
13
+ assigneeUserId: string | null;
14
+ requesterName: string;
15
+ requesterEmail: string;
16
+ attachments: string[];
17
+ reminderAt: string | null;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ };
21
+
22
+ type TicketFilterInput = Partial<
23
+ Pick<TicketSearchDocument, 'status' | 'priority' | 'categoryId' | 'assigneeUserId' | 'teamId'>
24
+ >;
25
+
26
+ const meilisearchHost = process.env.MEILISEARCH_HOST;
27
+ const meilisearchApiKey = process.env.MEILISEARCH_API_KEY;
28
+ const ticketSearchIndex = process.env.TICKETS_SEARCH_INDEX ?? 'support-tickets';
29
+
30
+ let indexPromise: Promise<Index<TicketSearchDocument>> | null = null;
31
+ let settingsPromise: Promise<void> | null = null;
32
+
33
+ const escapeFilterValue = (value: string) => value.replace(/"/g, '\\"');
34
+
35
+ export const getTicketIndex = async (): Promise<Index<TicketSearchDocument>> => {
36
+ if (!meilisearchHost || !meilisearchApiKey) {
37
+ throw new Error('Meilisearch is not configured');
38
+ }
39
+
40
+ if (!indexPromise) {
41
+ const client = new MeiliSearch({
42
+ host: meilisearchHost,
43
+ apiKey: meilisearchApiKey,
44
+ });
45
+ indexPromise = Promise.resolve(client.index<TicketSearchDocument>(ticketSearchIndex));
46
+ }
47
+
48
+ const index = await indexPromise;
49
+
50
+ if (!settingsPromise) {
51
+ settingsPromise = index
52
+ .updateSettings({
53
+ filterableAttributes: [
54
+ 'status',
55
+ 'priority',
56
+ 'categoryId',
57
+ 'assigneeUserId',
58
+ 'teamId',
59
+ ],
60
+ sortableAttributes: ['createdAt', 'updatedAt'],
61
+ })
62
+ .then(() => undefined)
63
+ .catch((error: unknown) => {
64
+ settingsPromise = null;
65
+ logger.error('Failed to configure ticket search index', { error });
66
+ throw error;
67
+ });
68
+ }
69
+
70
+ await settingsPromise;
71
+ return index;
72
+ };
73
+
74
+ export const buildTicketFilters = (filters: TicketFilterInput) => {
75
+ const parts: string[] = [];
76
+
77
+ if (filters.status) {
78
+ parts.push(`status = "${escapeFilterValue(filters.status)}"`);
79
+ }
80
+ if (filters.priority) {
81
+ parts.push(`priority = "${escapeFilterValue(filters.priority)}"`);
82
+ }
83
+ if (filters.categoryId) {
84
+ parts.push(`categoryId = "${escapeFilterValue(filters.categoryId)}"`);
85
+ }
86
+ if (filters.assigneeUserId) {
87
+ parts.push(`assigneeUserId = "${escapeFilterValue(filters.assigneeUserId)}"`);
88
+ }
89
+ if (filters.teamId) {
90
+ parts.push(`teamId = "${escapeFilterValue(filters.teamId)}"`);
91
+ }
92
+
93
+ return parts;
94
+ };
95
+
96
+ export const decodeOffsetToken = (token?: string) => {
97
+ if (!token) return 0;
98
+ try {
99
+ const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8'));
100
+ if (typeof parsed?.offset === 'number') {
101
+ return parsed.offset;
102
+ }
103
+ } catch (error) {
104
+ logger.warn('Failed to decode ticket list token, defaulting to offset 0', { error });
105
+ }
106
+ return 0;
107
+ };
108
+
109
+ export const encodeOffsetToken = (offset: number) =>
110
+ Buffer.from(JSON.stringify({ offset })).toString('base64');
111
+
112
+ export const mapTicketToDocument = (ticket: any): TicketSearchDocument => ({
113
+ id: ticket.id,
114
+ ticketNumber: ticket.ticketNumber,
115
+ subject: ticket.subject ?? '',
116
+ description: ticket.description ?? '',
117
+ status: ticket.status ?? 'Unknown',
118
+ priority: ticket.priority ?? null,
119
+ categoryId: ticket.categoryId ?? null,
120
+ teamId: ticket.teamId ?? null,
121
+ assigneeUserId: ticket.assigneeUserId ?? null,
122
+ requesterName: ticket.requesterName ?? '',
123
+ requesterEmail: ticket.requesterEmail ?? '',
124
+ attachments: Array.isArray(ticket.attachments) ? ticket.attachments : [],
125
+ reminderAt: ticket.reminderAt ?? null,
126
+ createdAt: ticket.createdAt ?? new Date().toISOString(),
127
+ updatedAt: ticket.updatedAt ?? ticket.createdAt ?? new Date().toISOString(),
128
+ });