create-aws-project 1.2.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.
Files changed (181) hide show
  1. package/README.md +118 -0
  2. package/dist/__tests__/generator/replace-tokens.spec.d.ts +1 -0
  3. package/dist/__tests__/generator/replace-tokens.spec.js +281 -0
  4. package/dist/__tests__/generator.spec.d.ts +1 -0
  5. package/dist/__tests__/generator.spec.js +162 -0
  6. package/dist/__tests__/validation/project-name.spec.d.ts +1 -0
  7. package/dist/__tests__/validation/project-name.spec.js +57 -0
  8. package/dist/__tests__/wizard.spec.d.ts +1 -0
  9. package/dist/__tests__/wizard.spec.js +232 -0
  10. package/dist/aws/iam.d.ts +75 -0
  11. package/dist/aws/iam.js +264 -0
  12. package/dist/aws/organizations.d.ts +79 -0
  13. package/dist/aws/organizations.js +168 -0
  14. package/dist/cli.d.ts +4 -0
  15. package/dist/cli.js +206 -0
  16. package/dist/commands/setup-github.d.ts +4 -0
  17. package/dist/commands/setup-github.js +185 -0
  18. package/dist/generator/copy-file.d.ts +15 -0
  19. package/dist/generator/copy-file.js +56 -0
  20. package/dist/generator/generate-project.d.ts +14 -0
  21. package/dist/generator/generate-project.js +81 -0
  22. package/dist/generator/index.d.ts +4 -0
  23. package/dist/generator/index.js +3 -0
  24. package/dist/generator/replace-tokens.d.ts +29 -0
  25. package/dist/generator/replace-tokens.js +68 -0
  26. package/dist/github/secrets.d.ts +109 -0
  27. package/dist/github/secrets.js +275 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +6 -0
  30. package/dist/prompts/auth.d.ts +3 -0
  31. package/dist/prompts/auth.js +23 -0
  32. package/dist/prompts/aws-config.d.ts +2 -0
  33. package/dist/prompts/aws-config.js +14 -0
  34. package/dist/prompts/features.d.ts +2 -0
  35. package/dist/prompts/features.js +10 -0
  36. package/dist/prompts/github-setup.d.ts +53 -0
  37. package/dist/prompts/github-setup.js +208 -0
  38. package/dist/prompts/org-structure.d.ts +9 -0
  39. package/dist/prompts/org-structure.js +93 -0
  40. package/dist/prompts/platforms.d.ts +2 -0
  41. package/dist/prompts/platforms.js +12 -0
  42. package/dist/prompts/project-name.d.ts +2 -0
  43. package/dist/prompts/project-name.js +8 -0
  44. package/dist/prompts/theme.d.ts +2 -0
  45. package/dist/prompts/theme.js +14 -0
  46. package/dist/templates/index.d.ts +4 -0
  47. package/dist/templates/index.js +2 -0
  48. package/dist/templates/manifest.d.ts +11 -0
  49. package/dist/templates/manifest.js +99 -0
  50. package/dist/templates/tokens.d.ts +39 -0
  51. package/dist/templates/tokens.js +37 -0
  52. package/dist/templates/types.d.ts +52 -0
  53. package/dist/templates/types.js +1 -0
  54. package/dist/types.d.ts +27 -0
  55. package/dist/types.js +1 -0
  56. package/dist/validation/project-name.d.ts +1 -0
  57. package/dist/validation/project-name.js +12 -0
  58. package/dist/wizard.d.ts +2 -0
  59. package/dist/wizard.js +81 -0
  60. package/package.json +68 -0
  61. package/templates/.github/actions/build-and-test/action.yml +24 -0
  62. package/templates/.github/actions/deploy-cdk/action.yml +46 -0
  63. package/templates/.github/actions/deploy-web/action.yml +72 -0
  64. package/templates/.github/actions/setup/action.yml +29 -0
  65. package/templates/.github/pull_request_template.md +15 -0
  66. package/templates/.github/workflows/deploy-dev.yml +80 -0
  67. package/templates/.github/workflows/deploy-prod.yml +67 -0
  68. package/templates/.github/workflows/deploy-stage.yml +77 -0
  69. package/templates/.github/workflows/pull-request.yml +72 -0
  70. package/templates/.vscode/extensions.json +7 -0
  71. package/templates/.vscode/settings.json +67 -0
  72. package/templates/apps/api/.eslintrc.json +18 -0
  73. package/templates/apps/api/cdk/app.ts +93 -0
  74. package/templates/apps/api/cdk/auth/cognito-stack.ts +164 -0
  75. package/templates/apps/api/cdk/cdk.json +73 -0
  76. package/templates/apps/api/cdk/deployment-user-stack.ts +187 -0
  77. package/templates/apps/api/cdk/org-stack.ts +67 -0
  78. package/templates/apps/api/cdk/static-stack.ts +361 -0
  79. package/templates/apps/api/cdk/tsconfig.json +39 -0
  80. package/templates/apps/api/cdk/user-stack.ts +255 -0
  81. package/templates/apps/api/jest.config.ts +38 -0
  82. package/templates/apps/api/lambdas.yml +84 -0
  83. package/templates/apps/api/project.json.template +58 -0
  84. package/templates/apps/api/src/__tests__/setup.ts +10 -0
  85. package/templates/apps/api/src/handlers/users/create-user.ts +52 -0
  86. package/templates/apps/api/src/handlers/users/delete-user.ts +45 -0
  87. package/templates/apps/api/src/handlers/users/get-me.ts +72 -0
  88. package/templates/apps/api/src/handlers/users/get-user.ts +45 -0
  89. package/templates/apps/api/src/handlers/users/get-users.ts +23 -0
  90. package/templates/apps/api/src/handlers/users/index.ts +17 -0
  91. package/templates/apps/api/src/handlers/users/update-user.ts +72 -0
  92. package/templates/apps/api/src/lib/dynamo/dynamo-model.ts +504 -0
  93. package/templates/apps/api/src/lib/dynamo/index.ts +12 -0
  94. package/templates/apps/api/src/lib/dynamo/utils.ts +39 -0
  95. package/templates/apps/api/src/middleware/auth0-auth.ts +97 -0
  96. package/templates/apps/api/src/middleware/cognito-auth.ts +90 -0
  97. package/templates/apps/api/src/models/UserModel.ts +109 -0
  98. package/templates/apps/api/src/schemas/user.schema.ts +44 -0
  99. package/templates/apps/api/src/services/user-service.ts +108 -0
  100. package/templates/apps/api/src/utils/auth-context.ts +60 -0
  101. package/templates/apps/api/src/utils/common/helpers.ts +26 -0
  102. package/templates/apps/api/src/utils/lambda-handler.ts +148 -0
  103. package/templates/apps/api/src/utils/response.ts +52 -0
  104. package/templates/apps/api/src/utils/validator.ts +75 -0
  105. package/templates/apps/api/tsconfig.app.json +15 -0
  106. package/templates/apps/api/tsconfig.json +19 -0
  107. package/templates/apps/api/tsconfig.spec.json +17 -0
  108. package/templates/apps/mobile/.env.example +5 -0
  109. package/templates/apps/mobile/.eslintrc.json +33 -0
  110. package/templates/apps/mobile/app.json +33 -0
  111. package/templates/apps/mobile/assets/.gitkeep +0 -0
  112. package/templates/apps/mobile/babel.config.js +19 -0
  113. package/templates/apps/mobile/index.js +7 -0
  114. package/templates/apps/mobile/jest.config.ts +22 -0
  115. package/templates/apps/mobile/metro.config.js +35 -0
  116. package/templates/apps/mobile/package.json +22 -0
  117. package/templates/apps/mobile/project.json.template +64 -0
  118. package/templates/apps/mobile/src/App.tsx +367 -0
  119. package/templates/apps/mobile/src/__tests__/App.spec.tsx +46 -0
  120. package/templates/apps/mobile/src/__tests__/store/user-store.spec.ts +156 -0
  121. package/templates/apps/mobile/src/config/api.ts +16 -0
  122. package/templates/apps/mobile/src/store/user-store.ts +56 -0
  123. package/templates/apps/mobile/src/test-setup.ts +10 -0
  124. package/templates/apps/mobile/tsconfig.json +22 -0
  125. package/templates/apps/web/.env.example +13 -0
  126. package/templates/apps/web/.eslintrc.json +26 -0
  127. package/templates/apps/web/index.html +13 -0
  128. package/templates/apps/web/jest.config.ts +24 -0
  129. package/templates/apps/web/package.json +15 -0
  130. package/templates/apps/web/project.json.template +66 -0
  131. package/templates/apps/web/src/App.tsx +352 -0
  132. package/templates/apps/web/src/__mocks__/config/api.ts +41 -0
  133. package/templates/apps/web/src/__tests__/App.spec.tsx +240 -0
  134. package/templates/apps/web/src/__tests__/store/user-store.spec.ts +185 -0
  135. package/templates/apps/web/src/auth/auth0-provider.tsx +103 -0
  136. package/templates/apps/web/src/auth/cognito-provider.tsx +143 -0
  137. package/templates/apps/web/src/auth/index.ts +7 -0
  138. package/templates/apps/web/src/auth/use-auth.ts +16 -0
  139. package/templates/apps/web/src/config/amplify-config.ts +31 -0
  140. package/templates/apps/web/src/config/api.ts +38 -0
  141. package/templates/apps/web/src/config/auth0-config.ts +17 -0
  142. package/templates/apps/web/src/main.tsx +41 -0
  143. package/templates/apps/web/src/store/user-store.ts +56 -0
  144. package/templates/apps/web/src/styles.css +165 -0
  145. package/templates/apps/web/src/test-setup.ts +1 -0
  146. package/templates/apps/web/src/theme/index.ts +30 -0
  147. package/templates/apps/web/src/vite-env.d.ts +19 -0
  148. package/templates/apps/web/tsconfig.app.json +24 -0
  149. package/templates/apps/web/tsconfig.json +22 -0
  150. package/templates/apps/web/tsconfig.spec.json +28 -0
  151. package/templates/apps/web/vite.config.ts +87 -0
  152. package/templates/manifest.json +28 -0
  153. package/templates/packages/api-client/.eslintrc.json +18 -0
  154. package/templates/packages/api-client/jest.config.ts +13 -0
  155. package/templates/packages/api-client/package.json +8 -0
  156. package/templates/packages/api-client/project.json.template +34 -0
  157. package/templates/packages/api-client/src/__tests__/api-client.spec.ts +408 -0
  158. package/templates/packages/api-client/src/api-client.ts +201 -0
  159. package/templates/packages/api-client/src/config.ts +193 -0
  160. package/templates/packages/api-client/src/index.ts +9 -0
  161. package/templates/packages/api-client/tsconfig.json +22 -0
  162. package/templates/packages/api-client/tsconfig.lib.json +11 -0
  163. package/templates/packages/api-client/tsconfig.spec.json +14 -0
  164. package/templates/packages/common-types/.eslintrc.json +18 -0
  165. package/templates/packages/common-types/package.json +6 -0
  166. package/templates/packages/common-types/project.json.template +26 -0
  167. package/templates/packages/common-types/src/api.types.ts +24 -0
  168. package/templates/packages/common-types/src/auth.types.ts +36 -0
  169. package/templates/packages/common-types/src/common.types.ts +46 -0
  170. package/templates/packages/common-types/src/index.ts +19 -0
  171. package/templates/packages/common-types/src/lambda.types.ts +39 -0
  172. package/templates/packages/common-types/src/user.types.ts +31 -0
  173. package/templates/packages/common-types/tsconfig.json +19 -0
  174. package/templates/packages/common-types/tsconfig.lib.json +11 -0
  175. package/templates/root/.editorconfig +23 -0
  176. package/templates/root/.nvmrc +1 -0
  177. package/templates/root/eslint.config.js +61 -0
  178. package/templates/root/jest.preset.js +16 -0
  179. package/templates/root/nx.json +29 -0
  180. package/templates/root/package.json +131 -0
  181. package/templates/root/tsconfig.base.json +29 -0
@@ -0,0 +1,504 @@
1
+ import {
2
+ DynamoDBClient,
3
+ GetItemCommand,
4
+ PutItemCommand,
5
+ UpdateItemCommand,
6
+ DeleteItemCommand,
7
+ ScanCommand,
8
+ QueryCommand,
9
+ BatchWriteItemCommand,
10
+ } from '@aws-sdk/client-dynamodb';
11
+ import { marshall, unmarshall } from '@aws-sdk/util-dynamodb';
12
+ import { Logger } from '@aws-lambda-powertools/logger';
13
+ import { removeGSIFields } from './utils';
14
+
15
+ /**
16
+ * Base model interface for DynamoDB entities
17
+ * All entities should extend this interface
18
+ */
19
+ export interface BaseModel {
20
+ id: string;
21
+ createdAt: string;
22
+ updatedAt?: string;
23
+ // GSI keys for flexible querying - common pattern across all models
24
+ pk1?: string;
25
+ sk1?: string;
26
+ pk2?: string;
27
+ sk2?: string;
28
+ pk3?: string;
29
+ sk3?: string;
30
+ pk4?: string;
31
+ sk4?: string;
32
+ pk5?: string;
33
+ sk5?: string;
34
+ pk6?: string;
35
+ sk6?: string;
36
+ }
37
+
38
+ /**
39
+ * Configuration options for DynamoModel
40
+ */
41
+ export interface DynamoModelConfig {
42
+ tableName: string;
43
+ region?: string;
44
+ logger?: Logger;
45
+ }
46
+
47
+ /**
48
+ * Abstract base class for DynamoDB models
49
+ * Provides common CRUD operations with GSI support
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * interface UserModel extends BaseModel {
54
+ * email: string;
55
+ * name: string;
56
+ * }
57
+ *
58
+ * class UserDynamoModel extends DynamoModel<UserModel> {
59
+ * protected getEntityName(): string {
60
+ * return 'User';
61
+ * }
62
+ *
63
+ * protected async generateId(): Promise<string> {
64
+ * return crypto.randomUUID();
65
+ * }
66
+ *
67
+ * protected setGSIKeys(entity: UserModel, now: string): void {
68
+ * entity.pk1 = 'USER';
69
+ * entity.sk1 = `EMAIL#${entity.email}`;
70
+ * }
71
+ * }
72
+ * ```
73
+ */
74
+ export abstract class DynamoModel<T extends BaseModel> {
75
+ protected dynamoClient: DynamoDBClient;
76
+ protected tableName: string;
77
+ protected logger: Logger;
78
+
79
+ constructor(tableName: string, region?: string, logger?: Logger);
80
+ constructor(config: DynamoModelConfig);
81
+ constructor(
82
+ tableNameOrConfig: string | DynamoModelConfig,
83
+ region?: string,
84
+ logger?: Logger
85
+ ) {
86
+ if (typeof tableNameOrConfig === 'string') {
87
+ this.tableName = tableNameOrConfig;
88
+ this.logger =
89
+ logger ||
90
+ new Logger({
91
+ serviceName: process.env['SERVICE_NAME'] || 'dynamo-client',
92
+ logLevel: (process.env['LOG_LEVEL'] as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR') || 'INFO',
93
+ environment: process.env['NODE_ENV'] || 'development',
94
+ });
95
+ this.dynamoClient = new DynamoDBClient({
96
+ region: region || process.env['AWS_REGION'],
97
+ });
98
+ } else {
99
+ const config = tableNameOrConfig;
100
+ this.tableName = config.tableName;
101
+ this.logger =
102
+ config.logger ||
103
+ new Logger({
104
+ serviceName: process.env['SERVICE_NAME'] || 'dynamo-client',
105
+ logLevel: (process.env['LOG_LEVEL'] as 'DEBUG' | 'INFO' | 'WARN' | 'ERROR') || 'INFO',
106
+ environment: process.env['NODE_ENV'] || 'development',
107
+ });
108
+ this.dynamoClient = new DynamoDBClient({
109
+ region: config.region || process.env['AWS_REGION'],
110
+ });
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get entity name for logging (e.g., "Project", "User", "ContractorGroup")
116
+ */
117
+ protected abstract getEntityName(): string;
118
+
119
+ /**
120
+ * Generate the primary key for the entity
121
+ */
122
+ protected abstract generateId(data: T): string | Promise<string>;
123
+
124
+ /**
125
+ * Set GSI keys for the entity based on business requirements
126
+ */
127
+ protected abstract setGSIKeys(entity: T, now: string): void;
128
+
129
+ /**
130
+ * Transform the data before saving (e.g., remove temporary fields)
131
+ */
132
+ protected transformForSave(entity: T): T | Promise<T> {
133
+ // Default implementation - subclasses can override
134
+ return entity;
135
+ }
136
+
137
+ protected getIdWithPrefix(id: string): string {
138
+ const entityName = this.getEntityName().toUpperCase();
139
+ if (id?.startsWith(entityName)) {
140
+ return id;
141
+ }
142
+
143
+ return `${entityName}#${id}`;
144
+ }
145
+
146
+ protected idHasPrefix(id: string): boolean {
147
+ const entityName = `${this.getEntityName().toUpperCase()}#`;
148
+ return id.startsWith(entityName);
149
+ }
150
+
151
+ protected cleanEntityId(id: string): string {
152
+ const entityName = this.getEntityName().toUpperCase();
153
+ if (id.startsWith(entityName)) {
154
+ return id.replace(`${entityName}#`, '');
155
+ }
156
+
157
+ return id;
158
+ }
159
+
160
+ protected cleanEntity(entity: T): T {
161
+ const result = {
162
+ ...removeGSIFields(entity as unknown as Record<string, unknown>),
163
+ id: this.cleanEntityId(entity.id),
164
+ } as T;
165
+
166
+ return result;
167
+ }
168
+
169
+ /**
170
+ * Get an entity by its ID
171
+ */
172
+ async getById(id: string): Promise<T | null> {
173
+ try {
174
+ this.logger.info(`Getting ${this.getEntityName()} by ID: ${id}`);
175
+
176
+ const command = new GetItemCommand({
177
+ TableName: this.tableName,
178
+ Key: marshall({ id: this.getIdWithPrefix(id) }, { removeUndefinedValues: true }),
179
+ });
180
+
181
+ const response = await this.dynamoClient.send(command);
182
+ if (!response.Item) {
183
+ this.logger.info(`${this.getEntityName()} not found: ${id}`);
184
+ return null;
185
+ }
186
+
187
+ const entity = unmarshall(response.Item) as T;
188
+ this.logger.info(`${this.getEntityName()} retrieved successfully: ${id}`);
189
+ return this.cleanEntity(entity);
190
+ } catch (error) {
191
+ this.logger.error(`Error retrieving ${this.getEntityName().toLowerCase()} ${id}:`, { error });
192
+ throw new Error(`Failed to retrieve ${this.getEntityName().toLowerCase()}: ${error}`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Create a new entity
198
+ */
199
+ async create(entityData: Omit<T, keyof BaseModel>): Promise<T> {
200
+ try {
201
+ const now = new Date().toISOString();
202
+ const entity: T = {
203
+ ...entityData,
204
+ createdAt: now,
205
+ updatedAt: now,
206
+ } as T;
207
+
208
+ // Set the ID
209
+ if (entity.id === undefined) {
210
+ entity.id = this.getIdWithPrefix(await this.generateId(entity));
211
+ } else {
212
+ entity.id = this.getIdWithPrefix(entity.id);
213
+ }
214
+
215
+ // Set GSI keys
216
+ this.setGSIKeys(entity, now);
217
+
218
+ // Transform before saving
219
+ const transformedEntity = await this.transformForSave(entity);
220
+
221
+ this.logger.info(`Creating ${this.getEntityName()}`, { entity });
222
+
223
+ const command = new PutItemCommand({
224
+ TableName: this.tableName,
225
+ Item: marshall(transformedEntity, { removeUndefinedValues: true }),
226
+ });
227
+
228
+ await this.dynamoClient.send(command);
229
+ this.logger.info(`${this.getEntityName()} created successfully: ${entity.id}`);
230
+
231
+ return {
232
+ ...this.cleanEntity(transformedEntity),
233
+ id: this.cleanEntityId(transformedEntity.id),
234
+ };
235
+ } catch (error) {
236
+ this.logger.error(`Error creating ${this.getEntityName().toLowerCase()}:`, { error });
237
+ throw new Error(`Failed to create ${this.getEntityName().toLowerCase()}: ${error}`);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Update an entity
243
+ */
244
+ async update(id: string, updates: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<T | null> {
245
+ try {
246
+ this.logger.info(`Updating ${this.getEntityName()} by ID: ${id}`, { updates });
247
+
248
+ const updateExpression: string[] = [];
249
+ const expressionAttributeNames: Record<string, string> = {};
250
+ const expressionAttributeValues: Record<string, unknown> = {};
251
+
252
+ // Add updatedAt automatically
253
+ updates.updatedAt = new Date().toISOString();
254
+
255
+ // Build update expression
256
+ Object.entries(updates).forEach(([key, value]) => {
257
+ if (value !== undefined) {
258
+ const attrName = `#${key}`;
259
+ const attrValue = `:${key}`;
260
+
261
+ updateExpression.push(`${attrName} = ${attrValue}`);
262
+ expressionAttributeNames[attrName] = key;
263
+ expressionAttributeValues[attrValue] = value;
264
+ }
265
+ });
266
+
267
+ if (updateExpression.length === 0) {
268
+ throw new Error('No valid updates provided');
269
+ }
270
+
271
+ const command = new UpdateItemCommand({
272
+ TableName: this.tableName,
273
+ Key: marshall({ id: this.getIdWithPrefix(id) }, { removeUndefinedValues: true }),
274
+ UpdateExpression: `SET ${updateExpression.join(', ')}`,
275
+ ExpressionAttributeNames: expressionAttributeNames,
276
+ ExpressionAttributeValues: marshall(expressionAttributeValues, {
277
+ removeUndefinedValues: true,
278
+ }),
279
+ ReturnValues: 'ALL_NEW',
280
+ });
281
+
282
+ const response = await this.dynamoClient.send(command);
283
+
284
+ if (!response.Attributes) {
285
+ this.logger.info(`${this.getEntityName()} not found for update: ${id}`);
286
+ return null;
287
+ }
288
+
289
+ const updatedEntity = unmarshall(response.Attributes) as T;
290
+ this.logger.info(`${this.getEntityName()} updated successfully: ${id}`);
291
+ return updatedEntity;
292
+ } catch (error) {
293
+ this.logger.error(`Error updating ${this.getEntityName().toLowerCase()} ${id}:`, { error });
294
+ throw new Error(`Failed to update ${this.getEntityName().toLowerCase()}: ${error}`);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Delete an entity
300
+ */
301
+ async delete(id: string): Promise<boolean> {
302
+ try {
303
+ this.logger.info(`Deleting ${this.getEntityName()} by ID: ${id}`);
304
+
305
+ const command = new DeleteItemCommand({
306
+ TableName: this.tableName,
307
+ Key: marshall({ id: this.getIdWithPrefix(id) }, { removeUndefinedValues: true }),
308
+ });
309
+
310
+ await this.dynamoClient.send(command);
311
+ this.logger.info(`${this.getEntityName()} deleted successfully: ${id}`);
312
+ return true;
313
+ } catch (error) {
314
+ this.logger.error(`Error deleting ${this.getEntityName().toLowerCase()} ${id}:`, { error });
315
+ throw new Error(`Failed to delete ${this.getEntityName().toLowerCase()}: ${error}`);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Delete multiple entities in batch using DynamoDB BatchWriteItem
321
+ * Processes up to 25 items per batch (DynamoDB limit)
322
+ * @param ids Array of entity IDs to delete
323
+ * @returns Object with success and failed arrays
324
+ */
325
+ async batchDelete(
326
+ ids: string[]
327
+ ): Promise<{ success: string[]; failed: Array<{ id: string; error: string }> }> {
328
+ try {
329
+ this.logger.info(`Batch deleting ${ids.length} ${this.getEntityName()}(s)`);
330
+ const results = {
331
+ success: [] as string[],
332
+ failed: [] as Array<{ id: string; error: string }>,
333
+ };
334
+
335
+ if (ids.length === 0) {
336
+ return results;
337
+ }
338
+
339
+ // DynamoDB BatchWriteItem can handle up to 25 items at a time
340
+ const BATCH_SIZE = 25;
341
+ const chunks = [];
342
+ for (let i = 0; i < ids.length; i += BATCH_SIZE) {
343
+ chunks.push(ids.slice(i, i + BATCH_SIZE));
344
+ }
345
+
346
+ // Process each chunk
347
+ for (const chunk of chunks) {
348
+ const deleteRequests = chunk.map((id) => ({
349
+ DeleteRequest: {
350
+ Key: marshall({ id: this.getIdWithPrefix(id) }, { removeUndefinedValues: true }),
351
+ },
352
+ }));
353
+
354
+ try {
355
+ const command = new BatchWriteItemCommand({
356
+ RequestItems: {
357
+ [this.tableName]: deleteRequests,
358
+ },
359
+ });
360
+
361
+ const response = await this.dynamoClient.send(command);
362
+
363
+ // Track successful deletions (all items in chunk minus any unprocessed items)
364
+ const unprocessedCount = response.UnprocessedItems?.[this.tableName]?.length || 0;
365
+ const successCount = chunk.length - unprocessedCount;
366
+
367
+ // Add successfully deleted IDs to results
368
+ chunk.slice(0, successCount).forEach((id) => {
369
+ results.success.push(id);
370
+ this.logger.info(`${this.getEntityName()} deleted successfully: ${id}`);
371
+ });
372
+
373
+ // Handle unprocessed items
374
+ if (response.UnprocessedItems && response.UnprocessedItems[this.tableName]) {
375
+ response.UnprocessedItems[this.tableName].forEach((_item, index: number) => {
376
+ const entityId = chunk[successCount + index];
377
+ results.failed.push({
378
+ id: entityId,
379
+ error: 'Item was not processed due to throughput limits or other issues',
380
+ });
381
+ this.logger.warn(`${this.getEntityName()} deletion unprocessed: ${entityId}`);
382
+ });
383
+ }
384
+ } catch (batchError: unknown) {
385
+ // If batch fails, mark all items in chunk as failed
386
+ const errorMessage =
387
+ batchError instanceof Error ? batchError.message : 'Batch write operation failed';
388
+ chunk.forEach((id) => {
389
+ results.failed.push({
390
+ id,
391
+ error: errorMessage,
392
+ });
393
+ this.logger.error(`${this.getEntityName()} deletion failed: ${id}`, {
394
+ error: batchError,
395
+ });
396
+ });
397
+ }
398
+ }
399
+
400
+ this.logger.info(`Batch delete completed for ${this.getEntityName()}`, {
401
+ total: ids.length,
402
+ success: results.success.length,
403
+ failed: results.failed.length,
404
+ });
405
+
406
+ return results;
407
+ } catch (error: unknown) {
408
+ this.logger.error(`Error in batch delete for ${this.getEntityName()}:`, { error });
409
+ const errorMessage = error instanceof Error ? error.message : String(error);
410
+ throw new Error(`Failed to batch delete ${this.getEntityName()}s: ${errorMessage}`);
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Scan all entities with optional filter
416
+ */
417
+ async scanAll(
418
+ filterExpression?: string,
419
+ expressionAttributeValues?: Record<string, unknown>
420
+ ): Promise<T[]> {
421
+ try {
422
+ const command = new ScanCommand({
423
+ TableName: this.tableName,
424
+ ...(filterExpression && { FilterExpression: filterExpression }),
425
+ ...(expressionAttributeValues && {
426
+ ExpressionAttributeValues: marshall(expressionAttributeValues, {
427
+ removeUndefinedValues: true,
428
+ }),
429
+ }),
430
+ });
431
+
432
+ const response = await this.dynamoClient.send(command);
433
+ if (!response.Items) return [];
434
+
435
+ const entities = response.Items.map((item) => {
436
+ return unmarshall(item) as T;
437
+ });
438
+
439
+ return entities
440
+ .filter((entity) => this.idHasPrefix(entity.id))
441
+ .map((entity) => this.cleanEntity(entity));
442
+ } catch (error) {
443
+ this.logger.error(`Error scanning ${this.getEntityName().toLowerCase()}s:`, { error });
444
+ throw new Error(`Failed to scan ${this.getEntityName().toLowerCase()}s: ${error}`);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Query items using GSI
450
+ */
451
+ async queryByGSI(
452
+ indexName: string,
453
+ keyConditionExpression: string,
454
+ expressionAttributeValues: Record<string, unknown>,
455
+ filterExpression?: string,
456
+ expressionAttributeNames?: Record<string, string>
457
+ ): Promise<T[]> {
458
+ try {
459
+ const command = new QueryCommand({
460
+ TableName: this.tableName,
461
+ IndexName: indexName,
462
+ KeyConditionExpression: keyConditionExpression,
463
+ ExpressionAttributeValues: marshall(expressionAttributeValues, {
464
+ removeUndefinedValues: true,
465
+ }),
466
+ ...(filterExpression && { FilterExpression: filterExpression }),
467
+ ...(expressionAttributeNames && { ExpressionAttributeNames: expressionAttributeNames }),
468
+ });
469
+
470
+ const response = await this.dynamoClient.send(command);
471
+ if (!response.Items) return [];
472
+
473
+ const entities = response.Items.map((item) => {
474
+ return unmarshall(item) as T;
475
+ });
476
+
477
+ this.logger.info(
478
+ `Retrieved ${entities.length} for ${this.getEntityName()}s by GSI: ${indexName}`,
479
+ { entities }
480
+ );
481
+ return entities
482
+ .filter((entity) => this.idHasPrefix(entity.id))
483
+ .map((entity) => this.cleanEntity(entity));
484
+ } catch (error: unknown) {
485
+ this.logger.error(`Error querying ${this.getEntityName()}s by GSI:`, { error });
486
+ const errorMessage = error instanceof Error ? error.message : String(error);
487
+ throw new Error(`Failed to query ${this.getEntityName()}s by GSI: ${errorMessage}`);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Get the DynamoDB client for advanced operations
493
+ */
494
+ protected getClient(): DynamoDBClient {
495
+ return this.dynamoClient;
496
+ }
497
+
498
+ /**
499
+ * Get the table name for advanced operations
500
+ */
501
+ protected getTableName(): string {
502
+ return this.tableName;
503
+ }
504
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * DynamoDB model base classes
3
+ * Provides abstract base class for DynamoDB CRUD operations with GSI support
4
+ */
5
+
6
+ export { DynamoModel, BaseModel, DynamoModelConfig } from './dynamo-model';
7
+ export {
8
+ removeGSIFields,
9
+ generateUUID,
10
+ getCurrentTimestamp,
11
+ GSIFieldNames,
12
+ } from './utils';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Utility functions for DynamoDB operations
3
+ */
4
+
5
+ /**
6
+ * GSI field names that are commonly used in DynamoDB models
7
+ */
8
+ export type GSIFieldNames =
9
+ | 'pk1' | 'sk1'
10
+ | 'pk2' | 'sk2'
11
+ | 'pk3' | 'sk3'
12
+ | 'pk4' | 'sk4'
13
+ | 'pk5' | 'sk5'
14
+ | 'pk6' | 'sk6';
15
+
16
+ /**
17
+ * Remove GSI (Global Secondary Index) fields from an entity
18
+ * Useful for cleaning data before returning to clients
19
+ */
20
+ export function removeGSIFields<T extends Record<string, unknown>>(
21
+ entity: T
22
+ ): Omit<T, GSIFieldNames> {
23
+ const { pk1: _pk1, sk1: _sk1, pk2: _pk2, sk2: _sk2, pk3: _pk3, sk3: _sk3, pk4: _pk4, sk4: _sk4, pk5: _pk5, sk5: _sk5, pk6: _pk6, sk6: _sk6, ...rest } = entity as Record<string, unknown>;
24
+ return rest as Omit<T, GSIFieldNames>;
25
+ }
26
+
27
+ /**
28
+ * Generate a unique ID using crypto.randomUUID
29
+ */
30
+ export function generateUUID(): string {
31
+ return crypto.randomUUID();
32
+ }
33
+
34
+ /**
35
+ * Get current ISO timestamp
36
+ */
37
+ export function getCurrentTimestamp(): string {
38
+ return new Date().toISOString();
39
+ }
@@ -0,0 +1,97 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+ import type { JWTPayload } from 'jose';
3
+ import { errorResponse } from '../utils/response';
4
+ import { ERROR_CODES, HTTP_STATUS } from '{{PACKAGE_SCOPE}}/common-types';
5
+
6
+ /**
7
+ * Authenticated user information extracted from Auth0 JWT token
8
+ */
9
+ export interface AuthUser {
10
+ /** Auth0 user ID (subject claim) */
11
+ sub: string;
12
+ /** User's email address (if available in token) */
13
+ email?: string;
14
+ /** Auth0 permissions assigned to the user */
15
+ permissions?: string[];
16
+ /** Full JWT payload for additional claims access */
17
+ tokenPayload: JWTPayload;
18
+ }
19
+
20
+ // Auth0 configuration from environment
21
+ const AUTH0_DOMAIN = process.env['AUTH0_DOMAIN']!;
22
+ const AUTH0_AUDIENCE = process.env['AUTH0_AUDIENCE']!;
23
+
24
+ // JWKS endpoint for Auth0
25
+ const jwksUri = `https://${AUTH0_DOMAIN}/.well-known/jwks.json`;
26
+
27
+ // Create JWKS client - instantiated outside handler for caching across Lambda invocations
28
+ const JWKS = createRemoteJWKSet(new URL(jwksUri));
29
+
30
+ /**
31
+ * Verify a JWT access token from the Authorization header
32
+ *
33
+ * @param authHeader - The Authorization header value (e.g., "Bearer <token>")
34
+ * @returns The authenticated user if token is valid, null otherwise
35
+ */
36
+ export async function verifyToken(authHeader: string | undefined): Promise<AuthUser | null> {
37
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
38
+ return null;
39
+ }
40
+
41
+ const token = authHeader.slice(7); // Remove 'Bearer ' prefix
42
+
43
+ try {
44
+ const { payload } = await jwtVerify(token, JWKS, {
45
+ issuer: `https://${AUTH0_DOMAIN}/`,
46
+ audience: AUTH0_AUDIENCE,
47
+ });
48
+
49
+ return {
50
+ sub: payload.sub!,
51
+ email: payload.email as string | undefined,
52
+ permissions: payload.permissions as string[] | undefined,
53
+ tokenPayload: payload,
54
+ };
55
+ } catch (error) {
56
+ console.error('Token verification failed:', error);
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Middleware wrapper that requires authentication for a handler
63
+ *
64
+ * Wraps a handler function to automatically verify the JWT token
65
+ * and pass the authenticated user to the handler.
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * export const handler = requireAuth(async (event, user) => {
70
+ * console.log('User ID:', user.sub);
71
+ * return successResponse({ userId: user.sub });
72
+ * });
73
+ * ```
74
+ *
75
+ * @param handler - Handler function that receives the event and authenticated user
76
+ * @returns Wrapped handler that validates authentication first
77
+ */
78
+ export function requireAuth<T>(
79
+ handler: (event: T, user: AuthUser) => Promise<unknown>
80
+ ): (event: T) => Promise<unknown> {
81
+ return async (event: T) => {
82
+ const authHeader = (event as Record<string, Record<string, string>>).headers?.authorization ||
83
+ (event as Record<string, Record<string, string>>).headers?.Authorization;
84
+
85
+ const user = await verifyToken(authHeader);
86
+
87
+ if (!user) {
88
+ return errorResponse(
89
+ ERROR_CODES.UNAUTHORIZED,
90
+ 'Invalid or missing authentication token',
91
+ HTTP_STATUS.UNAUTHORIZED
92
+ );
93
+ }
94
+
95
+ return handler(event, user);
96
+ };
97
+ }