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.
- package/README.md +118 -0
- package/dist/__tests__/generator/replace-tokens.spec.d.ts +1 -0
- package/dist/__tests__/generator/replace-tokens.spec.js +281 -0
- package/dist/__tests__/generator.spec.d.ts +1 -0
- package/dist/__tests__/generator.spec.js +162 -0
- package/dist/__tests__/validation/project-name.spec.d.ts +1 -0
- package/dist/__tests__/validation/project-name.spec.js +57 -0
- package/dist/__tests__/wizard.spec.d.ts +1 -0
- package/dist/__tests__/wizard.spec.js +232 -0
- package/dist/aws/iam.d.ts +75 -0
- package/dist/aws/iam.js +264 -0
- package/dist/aws/organizations.d.ts +79 -0
- package/dist/aws/organizations.js +168 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +206 -0
- package/dist/commands/setup-github.d.ts +4 -0
- package/dist/commands/setup-github.js +185 -0
- package/dist/generator/copy-file.d.ts +15 -0
- package/dist/generator/copy-file.js +56 -0
- package/dist/generator/generate-project.d.ts +14 -0
- package/dist/generator/generate-project.js +81 -0
- package/dist/generator/index.d.ts +4 -0
- package/dist/generator/index.js +3 -0
- package/dist/generator/replace-tokens.d.ts +29 -0
- package/dist/generator/replace-tokens.js +68 -0
- package/dist/github/secrets.d.ts +109 -0
- package/dist/github/secrets.js +275 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/prompts/auth.d.ts +3 -0
- package/dist/prompts/auth.js +23 -0
- package/dist/prompts/aws-config.d.ts +2 -0
- package/dist/prompts/aws-config.js +14 -0
- package/dist/prompts/features.d.ts +2 -0
- package/dist/prompts/features.js +10 -0
- package/dist/prompts/github-setup.d.ts +53 -0
- package/dist/prompts/github-setup.js +208 -0
- package/dist/prompts/org-structure.d.ts +9 -0
- package/dist/prompts/org-structure.js +93 -0
- package/dist/prompts/platforms.d.ts +2 -0
- package/dist/prompts/platforms.js +12 -0
- package/dist/prompts/project-name.d.ts +2 -0
- package/dist/prompts/project-name.js +8 -0
- package/dist/prompts/theme.d.ts +2 -0
- package/dist/prompts/theme.js +14 -0
- package/dist/templates/index.d.ts +4 -0
- package/dist/templates/index.js +2 -0
- package/dist/templates/manifest.d.ts +11 -0
- package/dist/templates/manifest.js +99 -0
- package/dist/templates/tokens.d.ts +39 -0
- package/dist/templates/tokens.js +37 -0
- package/dist/templates/types.d.ts +52 -0
- package/dist/templates/types.js +1 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +1 -0
- package/dist/validation/project-name.d.ts +1 -0
- package/dist/validation/project-name.js +12 -0
- package/dist/wizard.d.ts +2 -0
- package/dist/wizard.js +81 -0
- package/package.json +68 -0
- package/templates/.github/actions/build-and-test/action.yml +24 -0
- package/templates/.github/actions/deploy-cdk/action.yml +46 -0
- package/templates/.github/actions/deploy-web/action.yml +72 -0
- package/templates/.github/actions/setup/action.yml +29 -0
- package/templates/.github/pull_request_template.md +15 -0
- package/templates/.github/workflows/deploy-dev.yml +80 -0
- package/templates/.github/workflows/deploy-prod.yml +67 -0
- package/templates/.github/workflows/deploy-stage.yml +77 -0
- package/templates/.github/workflows/pull-request.yml +72 -0
- package/templates/.vscode/extensions.json +7 -0
- package/templates/.vscode/settings.json +67 -0
- package/templates/apps/api/.eslintrc.json +18 -0
- package/templates/apps/api/cdk/app.ts +93 -0
- package/templates/apps/api/cdk/auth/cognito-stack.ts +164 -0
- package/templates/apps/api/cdk/cdk.json +73 -0
- package/templates/apps/api/cdk/deployment-user-stack.ts +187 -0
- package/templates/apps/api/cdk/org-stack.ts +67 -0
- package/templates/apps/api/cdk/static-stack.ts +361 -0
- package/templates/apps/api/cdk/tsconfig.json +39 -0
- package/templates/apps/api/cdk/user-stack.ts +255 -0
- package/templates/apps/api/jest.config.ts +38 -0
- package/templates/apps/api/lambdas.yml +84 -0
- package/templates/apps/api/project.json.template +58 -0
- package/templates/apps/api/src/__tests__/setup.ts +10 -0
- package/templates/apps/api/src/handlers/users/create-user.ts +52 -0
- package/templates/apps/api/src/handlers/users/delete-user.ts +45 -0
- package/templates/apps/api/src/handlers/users/get-me.ts +72 -0
- package/templates/apps/api/src/handlers/users/get-user.ts +45 -0
- package/templates/apps/api/src/handlers/users/get-users.ts +23 -0
- package/templates/apps/api/src/handlers/users/index.ts +17 -0
- package/templates/apps/api/src/handlers/users/update-user.ts +72 -0
- package/templates/apps/api/src/lib/dynamo/dynamo-model.ts +504 -0
- package/templates/apps/api/src/lib/dynamo/index.ts +12 -0
- package/templates/apps/api/src/lib/dynamo/utils.ts +39 -0
- package/templates/apps/api/src/middleware/auth0-auth.ts +97 -0
- package/templates/apps/api/src/middleware/cognito-auth.ts +90 -0
- package/templates/apps/api/src/models/UserModel.ts +109 -0
- package/templates/apps/api/src/schemas/user.schema.ts +44 -0
- package/templates/apps/api/src/services/user-service.ts +108 -0
- package/templates/apps/api/src/utils/auth-context.ts +60 -0
- package/templates/apps/api/src/utils/common/helpers.ts +26 -0
- package/templates/apps/api/src/utils/lambda-handler.ts +148 -0
- package/templates/apps/api/src/utils/response.ts +52 -0
- package/templates/apps/api/src/utils/validator.ts +75 -0
- package/templates/apps/api/tsconfig.app.json +15 -0
- package/templates/apps/api/tsconfig.json +19 -0
- package/templates/apps/api/tsconfig.spec.json +17 -0
- package/templates/apps/mobile/.env.example +5 -0
- package/templates/apps/mobile/.eslintrc.json +33 -0
- package/templates/apps/mobile/app.json +33 -0
- package/templates/apps/mobile/assets/.gitkeep +0 -0
- package/templates/apps/mobile/babel.config.js +19 -0
- package/templates/apps/mobile/index.js +7 -0
- package/templates/apps/mobile/jest.config.ts +22 -0
- package/templates/apps/mobile/metro.config.js +35 -0
- package/templates/apps/mobile/package.json +22 -0
- package/templates/apps/mobile/project.json.template +64 -0
- package/templates/apps/mobile/src/App.tsx +367 -0
- package/templates/apps/mobile/src/__tests__/App.spec.tsx +46 -0
- package/templates/apps/mobile/src/__tests__/store/user-store.spec.ts +156 -0
- package/templates/apps/mobile/src/config/api.ts +16 -0
- package/templates/apps/mobile/src/store/user-store.ts +56 -0
- package/templates/apps/mobile/src/test-setup.ts +10 -0
- package/templates/apps/mobile/tsconfig.json +22 -0
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/.eslintrc.json +26 -0
- package/templates/apps/web/index.html +13 -0
- package/templates/apps/web/jest.config.ts +24 -0
- package/templates/apps/web/package.json +15 -0
- package/templates/apps/web/project.json.template +66 -0
- package/templates/apps/web/src/App.tsx +352 -0
- package/templates/apps/web/src/__mocks__/config/api.ts +41 -0
- package/templates/apps/web/src/__tests__/App.spec.tsx +240 -0
- package/templates/apps/web/src/__tests__/store/user-store.spec.ts +185 -0
- package/templates/apps/web/src/auth/auth0-provider.tsx +103 -0
- package/templates/apps/web/src/auth/cognito-provider.tsx +143 -0
- package/templates/apps/web/src/auth/index.ts +7 -0
- package/templates/apps/web/src/auth/use-auth.ts +16 -0
- package/templates/apps/web/src/config/amplify-config.ts +31 -0
- package/templates/apps/web/src/config/api.ts +38 -0
- package/templates/apps/web/src/config/auth0-config.ts +17 -0
- package/templates/apps/web/src/main.tsx +41 -0
- package/templates/apps/web/src/store/user-store.ts +56 -0
- package/templates/apps/web/src/styles.css +165 -0
- package/templates/apps/web/src/test-setup.ts +1 -0
- package/templates/apps/web/src/theme/index.ts +30 -0
- package/templates/apps/web/src/vite-env.d.ts +19 -0
- package/templates/apps/web/tsconfig.app.json +24 -0
- package/templates/apps/web/tsconfig.json +22 -0
- package/templates/apps/web/tsconfig.spec.json +28 -0
- package/templates/apps/web/vite.config.ts +87 -0
- package/templates/manifest.json +28 -0
- package/templates/packages/api-client/.eslintrc.json +18 -0
- package/templates/packages/api-client/jest.config.ts +13 -0
- package/templates/packages/api-client/package.json +8 -0
- package/templates/packages/api-client/project.json.template +34 -0
- package/templates/packages/api-client/src/__tests__/api-client.spec.ts +408 -0
- package/templates/packages/api-client/src/api-client.ts +201 -0
- package/templates/packages/api-client/src/config.ts +193 -0
- package/templates/packages/api-client/src/index.ts +9 -0
- package/templates/packages/api-client/tsconfig.json +22 -0
- package/templates/packages/api-client/tsconfig.lib.json +11 -0
- package/templates/packages/api-client/tsconfig.spec.json +14 -0
- package/templates/packages/common-types/.eslintrc.json +18 -0
- package/templates/packages/common-types/package.json +6 -0
- package/templates/packages/common-types/project.json.template +26 -0
- package/templates/packages/common-types/src/api.types.ts +24 -0
- package/templates/packages/common-types/src/auth.types.ts +36 -0
- package/templates/packages/common-types/src/common.types.ts +46 -0
- package/templates/packages/common-types/src/index.ts +19 -0
- package/templates/packages/common-types/src/lambda.types.ts +39 -0
- package/templates/packages/common-types/src/user.types.ts +31 -0
- package/templates/packages/common-types/tsconfig.json +19 -0
- package/templates/packages/common-types/tsconfig.lib.json +11 -0
- package/templates/root/.editorconfig +23 -0
- package/templates/root/.nvmrc +1 -0
- package/templates/root/eslint.config.js +61 -0
- package/templates/root/jest.preset.js +16 -0
- package/templates/root/nx.json +29 -0
- package/templates/root/package.json +131 -0
- 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
|
+
}
|