@fnd-platform/api 1.0.0-alpha.1 → 1.0.0-alpha.11
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/lib/api-project.d.ts +80 -64
- package/lib/api-project.d.ts.map +1 -1
- package/lib/api-project.js +971 -1
- package/lib/api-project.js.map +1 -1
- package/lib/handlers/content.d.ts +42 -9
- package/lib/handlers/content.d.ts.map +1 -1
- package/lib/handlers/content.js +359 -54
- package/lib/handlers/content.js.map +1 -1
- package/lib/handlers/health.js +10 -10
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -1
- package/lib/index.js.map +1 -1
- package/lib/lib/errors.js +56 -62
- package/lib/lib/keys.d.ts +122 -0
- package/lib/lib/keys.d.ts.map +1 -0
- package/lib/lib/keys.js +116 -0
- package/lib/lib/keys.js.map +1 -0
- package/lib/middleware/auth.js +25 -25
- package/lib/middleware/cors.js +29 -34
- package/lib/middleware/error-handler.js +22 -21
- package/lib/middleware/index.js +14 -44
- package/lib/middleware/logging.js +26 -30
- package/lib/middleware/validation.js +30 -29
- package/lib/options.js +3 -3
- package/package.json +4 -2
package/lib/api-project.js
CHANGED
|
@@ -81,7 +81,6 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
81
81
|
rootDir: 'src',
|
|
82
82
|
declaration: true,
|
|
83
83
|
declarationMap: true,
|
|
84
|
-
sourceMap: true,
|
|
85
84
|
strict: true,
|
|
86
85
|
esModuleInterop: true,
|
|
87
86
|
skipLibCheck: true,
|
|
@@ -99,6 +98,8 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
99
98
|
this.dynamodbEnabled = options.dynamodb ?? true;
|
|
100
99
|
this.cognitoEnabled = options.cognito ?? true;
|
|
101
100
|
this.corsEnabled = options.cors ?? true;
|
|
101
|
+
// Add @fnd-platform/api to parent's devDependencies (for .projenrc.ts imports)
|
|
102
|
+
this.parentProject.addDevDeps('@fnd-platform/api@^1.0.0-alpha.1');
|
|
102
103
|
// Add dependencies
|
|
103
104
|
this.addApiDependencies();
|
|
104
105
|
// Generate directory structure and files
|
|
@@ -114,6 +115,8 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
114
115
|
if (this.dynamodbEnabled) {
|
|
115
116
|
this.addDeps('@aws-sdk/client-dynamodb', '@aws-sdk/lib-dynamodb');
|
|
116
117
|
}
|
|
118
|
+
// S3 dependencies for media uploads
|
|
119
|
+
this.addDeps('@aws-sdk/client-s3', '@aws-sdk/s3-request-presigner');
|
|
117
120
|
// Dev dependencies
|
|
118
121
|
this.addDevDeps('@types/aws-lambda', 'esbuild', 'vitest', '@vitest/coverage-v8');
|
|
119
122
|
}
|
|
@@ -125,6 +128,16 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
125
128
|
new projen_1.SampleFile(this, 'src/handlers/health.ts', {
|
|
126
129
|
contents: this.getHealthHandlerTemplate(),
|
|
127
130
|
});
|
|
131
|
+
// Content CRUD handler - included when DynamoDB is enabled
|
|
132
|
+
if (this.dynamodbEnabled) {
|
|
133
|
+
new projen_1.SampleFile(this, 'src/handlers/content.ts', {
|
|
134
|
+
contents: this.getContentHandlerTemplate(),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// Media upload handler - always included for S3 uploads
|
|
138
|
+
new projen_1.SampleFile(this, 'src/handlers/media.ts', {
|
|
139
|
+
contents: this.getMediaHandlerTemplate(),
|
|
140
|
+
});
|
|
128
141
|
}
|
|
129
142
|
/**
|
|
130
143
|
* Generates the lib directory with utility files.
|
|
@@ -138,6 +151,16 @@ class FndApiProject extends projen_1.typescript.TypeScriptProject {
|
|
|
138
151
|
new projen_1.SampleFile(this, 'src/lib/errors.ts', {
|
|
139
152
|
contents: this.getErrorsTemplate(),
|
|
140
153
|
});
|
|
154
|
+
// Request utilities
|
|
155
|
+
new projen_1.SampleFile(this, 'src/lib/request.ts', {
|
|
156
|
+
contents: this.getRequestTemplate(),
|
|
157
|
+
});
|
|
158
|
+
// DynamoDB key builders (included when DynamoDB is enabled)
|
|
159
|
+
if (this.dynamodbEnabled) {
|
|
160
|
+
new projen_1.SampleFile(this, 'src/lib/keys.ts', {
|
|
161
|
+
contents: this.getKeysTemplate(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
141
164
|
// Middleware utilities (stub for Sprint 03)
|
|
142
165
|
new projen_1.SampleFile(this, 'src/lib/middleware.ts', {
|
|
143
166
|
contents: this.getMiddlewareTemplate(),
|
|
@@ -661,6 +684,953 @@ export interface UserEntity extends BaseEntity {
|
|
|
661
684
|
}
|
|
662
685
|
|
|
663
686
|
// Add your custom entity types below
|
|
687
|
+
`;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Returns the request utilities template content.
|
|
691
|
+
*/
|
|
692
|
+
getRequestTemplate() {
|
|
693
|
+
return `import type { APIGatewayProxyEvent } from 'aws-lambda';
|
|
694
|
+
import { ValidationError, UnauthorizedError } from './errors';
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* API Gateway event with Cognito authorizer claims.
|
|
698
|
+
*/
|
|
699
|
+
interface AuthenticatedEvent extends APIGatewayProxyEvent {
|
|
700
|
+
requestContext: APIGatewayProxyEvent['requestContext'] & {
|
|
701
|
+
authorizer: {
|
|
702
|
+
claims: {
|
|
703
|
+
sub: string;
|
|
704
|
+
email: string;
|
|
705
|
+
'cognito:groups'?: string[];
|
|
706
|
+
};
|
|
707
|
+
};
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Parse JSON body from API Gateway event.
|
|
713
|
+
*
|
|
714
|
+
* @param event - API Gateway proxy event
|
|
715
|
+
* @returns Parsed JSON body as type T
|
|
716
|
+
* @throws {ValidationError} If body is missing or invalid JSON
|
|
717
|
+
*/
|
|
718
|
+
export function parseBody<T>(event: APIGatewayProxyEvent): T {
|
|
719
|
+
if (!event.body) {
|
|
720
|
+
throw new ValidationError('Request body is required');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
try {
|
|
724
|
+
return JSON.parse(event.body) as T;
|
|
725
|
+
} catch {
|
|
726
|
+
throw new ValidationError('Invalid JSON in request body');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Get a required path parameter from the event.
|
|
732
|
+
*
|
|
733
|
+
* @param event - API Gateway proxy event
|
|
734
|
+
* @param name - Name of the path parameter
|
|
735
|
+
* @returns The path parameter value
|
|
736
|
+
* @throws {ValidationError} If the parameter is missing
|
|
737
|
+
*/
|
|
738
|
+
export function requirePathParam(event: APIGatewayProxyEvent, name: string): string {
|
|
739
|
+
const value = event.pathParameters?.[name];
|
|
740
|
+
if (!value) {
|
|
741
|
+
throw new ValidationError(\`Missing path parameter: \${name}\`);
|
|
742
|
+
}
|
|
743
|
+
return value;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Get an optional query parameter from the event.
|
|
748
|
+
*
|
|
749
|
+
* @param event - API Gateway proxy event
|
|
750
|
+
* @param name - Name of the query parameter
|
|
751
|
+
* @param defaultValue - Optional default value if parameter is missing
|
|
752
|
+
* @returns The query parameter value or default
|
|
753
|
+
*/
|
|
754
|
+
export function getQueryParam(
|
|
755
|
+
event: APIGatewayProxyEvent,
|
|
756
|
+
name: string,
|
|
757
|
+
defaultValue?: string
|
|
758
|
+
): string | undefined {
|
|
759
|
+
return event.queryStringParameters?.[name] ?? defaultValue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Get user ID from an authenticated request.
|
|
764
|
+
*
|
|
765
|
+
* @param event - Authenticated API Gateway event
|
|
766
|
+
* @returns The user ID from the JWT token
|
|
767
|
+
* @throws {UnauthorizedError} If user ID is not found in the token
|
|
768
|
+
*/
|
|
769
|
+
export function getUserId(event: AuthenticatedEvent): string {
|
|
770
|
+
const userId = event.requestContext.authorizer?.claims?.sub;
|
|
771
|
+
if (!userId) {
|
|
772
|
+
throw new UnauthorizedError('User ID not found in token');
|
|
773
|
+
}
|
|
774
|
+
return userId;
|
|
775
|
+
}
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Returns the DynamoDB key builders template content.
|
|
780
|
+
*/
|
|
781
|
+
getKeysTemplate() {
|
|
782
|
+
return `/**
|
|
783
|
+
* DynamoDB key builders for single-table design.
|
|
784
|
+
*
|
|
785
|
+
* These helpers generate consistent key structures for content entities
|
|
786
|
+
* following the fnd-platform single-table design patterns.
|
|
787
|
+
*/
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Key structure for DynamoDB primary key.
|
|
791
|
+
*/
|
|
792
|
+
export interface PrimaryKey {
|
|
793
|
+
PK: string;
|
|
794
|
+
SK: string;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Key structure for GSI1 (slug lookup).
|
|
799
|
+
*/
|
|
800
|
+
export interface GSI1Key {
|
|
801
|
+
GSI1PK: string;
|
|
802
|
+
GSI1SK: string;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Key structure for GSI2 (type/status listing).
|
|
807
|
+
*/
|
|
808
|
+
export interface GSI2Key {
|
|
809
|
+
GSI2PK: string;
|
|
810
|
+
GSI2SK: string;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Content key builders for DynamoDB operations.
|
|
815
|
+
*/
|
|
816
|
+
export const contentKeys = {
|
|
817
|
+
/**
|
|
818
|
+
* Generate partition key for content item.
|
|
819
|
+
*/
|
|
820
|
+
pk: (id: string): string => \`CONTENT#\${id}\`,
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Generate sort key for content item.
|
|
824
|
+
*/
|
|
825
|
+
sk: (): string => 'CONTENT',
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Generate full primary key for content item.
|
|
829
|
+
*/
|
|
830
|
+
keys: (id: string): PrimaryKey => ({
|
|
831
|
+
PK: contentKeys.pk(id),
|
|
832
|
+
SK: contentKeys.sk(),
|
|
833
|
+
}),
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* GSI1 key builders for slug-based lookups.
|
|
837
|
+
*/
|
|
838
|
+
gsi1: {
|
|
839
|
+
/**
|
|
840
|
+
* Generate GSI1 keys for looking up content by slug.
|
|
841
|
+
*/
|
|
842
|
+
bySlug: (slug: string): GSI1Key => ({
|
|
843
|
+
GSI1PK: \`CONTENT#SLUG#\${slug}\`,
|
|
844
|
+
GSI1SK: 'CONTENT',
|
|
845
|
+
}),
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* GSI2 key builders for listing content by type.
|
|
850
|
+
*/
|
|
851
|
+
gsi2: {
|
|
852
|
+
/**
|
|
853
|
+
* Generate GSI2 keys for listing content by type with timestamp sorting.
|
|
854
|
+
*/
|
|
855
|
+
byType: (contentType: string, timestamp: string): GSI2Key => ({
|
|
856
|
+
GSI2PK: contentType,
|
|
857
|
+
GSI2SK: timestamp,
|
|
858
|
+
}),
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* User key builders for DynamoDB operations.
|
|
864
|
+
*/
|
|
865
|
+
export const userKeys = {
|
|
866
|
+
/**
|
|
867
|
+
* Generate partition key for user.
|
|
868
|
+
*/
|
|
869
|
+
pk: (id: string): string => \`USER#\${id}\`,
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Sort key generators for user-related items.
|
|
873
|
+
*/
|
|
874
|
+
sk: {
|
|
875
|
+
/**
|
|
876
|
+
* Generate sort key for user profile.
|
|
877
|
+
*/
|
|
878
|
+
profile: (): string => 'PROFILE',
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Generate full primary key for user profile.
|
|
883
|
+
*/
|
|
884
|
+
keys: (id: string): PrimaryKey => ({
|
|
885
|
+
PK: userKeys.pk(id),
|
|
886
|
+
SK: userKeys.sk.profile(),
|
|
887
|
+
}),
|
|
888
|
+
};
|
|
889
|
+
`;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Returns the content handler template content.
|
|
893
|
+
*/
|
|
894
|
+
getContentHandlerTemplate() {
|
|
895
|
+
return `/**
|
|
896
|
+
* Content CRUD handlers with DynamoDB operations.
|
|
897
|
+
*
|
|
898
|
+
* Implements full CRUD operations for content entities using
|
|
899
|
+
* single-table design patterns with GSI support for efficient queries.
|
|
900
|
+
*/
|
|
901
|
+
|
|
902
|
+
import type { APIGatewayProxyHandler, APIGatewayProxyEvent } from 'aws-lambda';
|
|
903
|
+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
904
|
+
import {
|
|
905
|
+
DynamoDBDocumentClient,
|
|
906
|
+
QueryCommand,
|
|
907
|
+
GetCommand,
|
|
908
|
+
PutCommand,
|
|
909
|
+
UpdateCommand,
|
|
910
|
+
DeleteCommand,
|
|
911
|
+
} from '@aws-sdk/lib-dynamodb';
|
|
912
|
+
import { success, notFound, created, badRequest } from '../lib/response';
|
|
913
|
+
import { parseBody, requirePathParam, getQueryParam, getUserId } from '../lib/request';
|
|
914
|
+
import { contentKeys } from '../lib/keys';
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Content item interface.
|
|
918
|
+
*/
|
|
919
|
+
interface ContentItem {
|
|
920
|
+
id: string;
|
|
921
|
+
slug: string;
|
|
922
|
+
title: string;
|
|
923
|
+
excerpt?: string;
|
|
924
|
+
content: string;
|
|
925
|
+
contentType: string;
|
|
926
|
+
status: 'draft' | 'published';
|
|
927
|
+
authorId: string;
|
|
928
|
+
createdAt: string;
|
|
929
|
+
updatedAt: string;
|
|
930
|
+
publishedAt?: string;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Input for creating new content.
|
|
935
|
+
*/
|
|
936
|
+
interface CreateContentInput {
|
|
937
|
+
slug: string;
|
|
938
|
+
title: string;
|
|
939
|
+
excerpt?: string;
|
|
940
|
+
content: string;
|
|
941
|
+
contentType: string;
|
|
942
|
+
status?: 'draft' | 'published';
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Input for updating existing content.
|
|
947
|
+
*/
|
|
948
|
+
interface UpdateContentInput {
|
|
949
|
+
slug?: string;
|
|
950
|
+
title?: string;
|
|
951
|
+
excerpt?: string;
|
|
952
|
+
content?: string;
|
|
953
|
+
contentType?: string;
|
|
954
|
+
status?: 'draft' | 'published';
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Authenticated event type with Cognito claims.
|
|
959
|
+
*/
|
|
960
|
+
interface AuthenticatedEvent extends APIGatewayProxyEvent {
|
|
961
|
+
requestContext: APIGatewayProxyEvent['requestContext'] & {
|
|
962
|
+
authorizer: {
|
|
963
|
+
claims: { sub: string; email: string; 'cognito:groups'?: string[] };
|
|
964
|
+
};
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Initialize DynamoDB client
|
|
969
|
+
const ddbClient = new DynamoDBClient({});
|
|
970
|
+
const client = DynamoDBDocumentClient.from(ddbClient, {
|
|
971
|
+
marshallOptions: {
|
|
972
|
+
removeUndefinedValues: true,
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
const TABLE_NAME = process.env.TABLE_NAME ?? '';
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Map DynamoDB item to ContentItem.
|
|
980
|
+
*/
|
|
981
|
+
function mapToContentItem(item: Record<string, unknown>): ContentItem {
|
|
982
|
+
return {
|
|
983
|
+
id: item.id as string,
|
|
984
|
+
slug: item.slug as string,
|
|
985
|
+
title: item.title as string,
|
|
986
|
+
excerpt: item.excerpt as string | undefined,
|
|
987
|
+
content: item.content as string,
|
|
988
|
+
contentType: item.contentType as string,
|
|
989
|
+
status: item.status as 'draft' | 'published',
|
|
990
|
+
authorId: item.authorId as string,
|
|
991
|
+
createdAt: item.createdAt as string,
|
|
992
|
+
updatedAt: item.updatedAt as string,
|
|
993
|
+
publishedAt: item.publishedAt as string | undefined,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* GET /content - List content items.
|
|
999
|
+
*/
|
|
1000
|
+
export const list: APIGatewayProxyHandler = async (event) => {
|
|
1001
|
+
const limit = Math.min(parseInt(getQueryParam(event, 'limit', '20') ?? '20', 10), 100);
|
|
1002
|
+
const cursor = getQueryParam(event, 'cursor');
|
|
1003
|
+
const contentType = getQueryParam(event, 'type') ?? 'blog-post';
|
|
1004
|
+
const status = getQueryParam(event, 'status') ?? 'published';
|
|
1005
|
+
|
|
1006
|
+
const result = await client.send(
|
|
1007
|
+
new QueryCommand({
|
|
1008
|
+
TableName: TABLE_NAME,
|
|
1009
|
+
IndexName: 'GSI2',
|
|
1010
|
+
KeyConditionExpression: 'GSI2PK = :pk',
|
|
1011
|
+
FilterExpression: '#status = :status',
|
|
1012
|
+
ExpressionAttributeNames: { '#status': 'status' },
|
|
1013
|
+
ExpressionAttributeValues: {
|
|
1014
|
+
':pk': contentType,
|
|
1015
|
+
':status': status,
|
|
1016
|
+
},
|
|
1017
|
+
ScanIndexForward: false,
|
|
1018
|
+
Limit: limit,
|
|
1019
|
+
ExclusiveStartKey: cursor
|
|
1020
|
+
? JSON.parse(Buffer.from(cursor, 'base64').toString())
|
|
1021
|
+
: undefined,
|
|
1022
|
+
})
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
const items = (result.Items ?? []).map(mapToContentItem);
|
|
1026
|
+
|
|
1027
|
+
return success({
|
|
1028
|
+
items,
|
|
1029
|
+
nextCursor: result.LastEvaluatedKey
|
|
1030
|
+
? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64')
|
|
1031
|
+
: undefined,
|
|
1032
|
+
});
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* GET /content/:id - Get content by ID.
|
|
1037
|
+
*/
|
|
1038
|
+
export const get: APIGatewayProxyHandler = async (event) => {
|
|
1039
|
+
const id = requirePathParam(event, 'id');
|
|
1040
|
+
const keys = contentKeys.keys(id);
|
|
1041
|
+
|
|
1042
|
+
const result = await client.send(
|
|
1043
|
+
new GetCommand({
|
|
1044
|
+
TableName: TABLE_NAME,
|
|
1045
|
+
Key: keys,
|
|
1046
|
+
})
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
if (!result.Item) {
|
|
1050
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return success(mapToContentItem(result.Item));
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* GET /content/slug/:slug - Get content by slug.
|
|
1058
|
+
*/
|
|
1059
|
+
export const getBySlug: APIGatewayProxyHandler = async (event) => {
|
|
1060
|
+
const slug = requirePathParam(event, 'slug');
|
|
1061
|
+
const gsi1Keys = contentKeys.gsi1.bySlug(slug);
|
|
1062
|
+
|
|
1063
|
+
const result = await client.send(
|
|
1064
|
+
new QueryCommand({
|
|
1065
|
+
TableName: TABLE_NAME,
|
|
1066
|
+
IndexName: 'GSI1',
|
|
1067
|
+
KeyConditionExpression: 'GSI1PK = :pk AND GSI1SK = :sk',
|
|
1068
|
+
ExpressionAttributeValues: {
|
|
1069
|
+
':pk': gsi1Keys.GSI1PK,
|
|
1070
|
+
':sk': gsi1Keys.GSI1SK,
|
|
1071
|
+
},
|
|
1072
|
+
Limit: 1,
|
|
1073
|
+
})
|
|
1074
|
+
);
|
|
1075
|
+
|
|
1076
|
+
if (!result.Items?.length) {
|
|
1077
|
+
return notFound(\`Content with slug '\${slug}' not found\`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return success(mapToContentItem(result.Items[0]));
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* POST /content - Create new content.
|
|
1085
|
+
*/
|
|
1086
|
+
export const create: APIGatewayProxyHandler = async (event) => {
|
|
1087
|
+
const body = parseBody<CreateContentInput>(event);
|
|
1088
|
+
|
|
1089
|
+
if (!body.slug || !body.title || !body.content || !body.contentType) {
|
|
1090
|
+
return badRequest('Missing required fields: slug, title, content, contentType');
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (!/^[a-z0-9-]+$/.test(body.slug)) {
|
|
1094
|
+
return badRequest('Slug must contain only lowercase letters, numbers, and hyphens');
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Check slug uniqueness
|
|
1098
|
+
const gsi1Keys = contentKeys.gsi1.bySlug(body.slug);
|
|
1099
|
+
const existingResult = await client.send(
|
|
1100
|
+
new QueryCommand({
|
|
1101
|
+
TableName: TABLE_NAME,
|
|
1102
|
+
IndexName: 'GSI1',
|
|
1103
|
+
KeyConditionExpression: 'GSI1PK = :pk AND GSI1SK = :sk',
|
|
1104
|
+
ExpressionAttributeValues: {
|
|
1105
|
+
':pk': gsi1Keys.GSI1PK,
|
|
1106
|
+
':sk': gsi1Keys.GSI1SK,
|
|
1107
|
+
},
|
|
1108
|
+
Limit: 1,
|
|
1109
|
+
})
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
if (existingResult.Items?.length) {
|
|
1113
|
+
return badRequest(\`Content with slug '\${body.slug}' already exists\`);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const authorId = getUserId(event as AuthenticatedEvent);
|
|
1117
|
+
const now = new Date().toISOString();
|
|
1118
|
+
const id = crypto.randomUUID();
|
|
1119
|
+
const status = body.status ?? 'draft';
|
|
1120
|
+
|
|
1121
|
+
const keys = contentKeys.keys(id);
|
|
1122
|
+
const gsi2Keys = contentKeys.gsi2.byType(body.contentType, now);
|
|
1123
|
+
|
|
1124
|
+
const item = {
|
|
1125
|
+
...keys,
|
|
1126
|
+
...gsi1Keys,
|
|
1127
|
+
...gsi2Keys,
|
|
1128
|
+
id,
|
|
1129
|
+
slug: body.slug,
|
|
1130
|
+
title: body.title,
|
|
1131
|
+
excerpt: body.excerpt,
|
|
1132
|
+
content: body.content,
|
|
1133
|
+
contentType: body.contentType,
|
|
1134
|
+
status,
|
|
1135
|
+
authorId,
|
|
1136
|
+
createdAt: now,
|
|
1137
|
+
updatedAt: now,
|
|
1138
|
+
publishedAt: status === 'published' ? now : undefined,
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
await client.send(
|
|
1142
|
+
new PutCommand({
|
|
1143
|
+
TableName: TABLE_NAME,
|
|
1144
|
+
Item: item,
|
|
1145
|
+
})
|
|
1146
|
+
);
|
|
1147
|
+
|
|
1148
|
+
return created(mapToContentItem(item));
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* PUT /content/:id - Update content.
|
|
1153
|
+
*/
|
|
1154
|
+
export const update: APIGatewayProxyHandler = async (event) => {
|
|
1155
|
+
const id = requirePathParam(event, 'id');
|
|
1156
|
+
const body = parseBody<UpdateContentInput>(event);
|
|
1157
|
+
const keys = contentKeys.keys(id);
|
|
1158
|
+
|
|
1159
|
+
const existingResult = await client.send(
|
|
1160
|
+
new GetCommand({
|
|
1161
|
+
TableName: TABLE_NAME,
|
|
1162
|
+
Key: keys,
|
|
1163
|
+
})
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
if (!existingResult.Item) {
|
|
1167
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const existingItem = existingResult.Item;
|
|
1171
|
+
const now = new Date().toISOString();
|
|
1172
|
+
|
|
1173
|
+
// Build update expression
|
|
1174
|
+
const updateExpressionParts: string[] = ['#updatedAt = :updatedAt'];
|
|
1175
|
+
const expressionAttributeNames: Record<string, string> = { '#updatedAt': 'updatedAt' };
|
|
1176
|
+
const expressionAttributeValues: Record<string, unknown> = { ':updatedAt': now };
|
|
1177
|
+
|
|
1178
|
+
if (body.slug && body.slug !== existingItem.slug) {
|
|
1179
|
+
if (!/^[a-z0-9-]+$/.test(body.slug)) {
|
|
1180
|
+
return badRequest('Slug must contain only lowercase letters, numbers, and hyphens');
|
|
1181
|
+
}
|
|
1182
|
+
const newGsi1Keys = contentKeys.gsi1.bySlug(body.slug);
|
|
1183
|
+
updateExpressionParts.push('#slug = :slug', 'GSI1PK = :gsi1pk', 'GSI1SK = :gsi1sk');
|
|
1184
|
+
expressionAttributeNames['#slug'] = 'slug';
|
|
1185
|
+
expressionAttributeValues[':slug'] = body.slug;
|
|
1186
|
+
expressionAttributeValues[':gsi1pk'] = newGsi1Keys.GSI1PK;
|
|
1187
|
+
expressionAttributeValues[':gsi1sk'] = newGsi1Keys.GSI1SK;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (body.title !== undefined) {
|
|
1191
|
+
updateExpressionParts.push('#title = :title');
|
|
1192
|
+
expressionAttributeNames['#title'] = 'title';
|
|
1193
|
+
expressionAttributeValues[':title'] = body.title;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (body.excerpt !== undefined) {
|
|
1197
|
+
updateExpressionParts.push('excerpt = :excerpt');
|
|
1198
|
+
expressionAttributeValues[':excerpt'] = body.excerpt;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (body.content !== undefined) {
|
|
1202
|
+
updateExpressionParts.push('#content = :content');
|
|
1203
|
+
expressionAttributeNames['#content'] = 'content';
|
|
1204
|
+
expressionAttributeValues[':content'] = body.content;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (body.contentType !== undefined) {
|
|
1208
|
+
const newGsi2Keys = contentKeys.gsi2.byType(body.contentType, existingItem.createdAt as string);
|
|
1209
|
+
updateExpressionParts.push('contentType = :contentType', 'GSI2PK = :gsi2pk');
|
|
1210
|
+
expressionAttributeValues[':contentType'] = body.contentType;
|
|
1211
|
+
expressionAttributeValues[':gsi2pk'] = newGsi2Keys.GSI2PK;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (body.status !== undefined) {
|
|
1215
|
+
updateExpressionParts.push('#status = :status');
|
|
1216
|
+
expressionAttributeNames['#status'] = 'status';
|
|
1217
|
+
expressionAttributeValues[':status'] = body.status;
|
|
1218
|
+
|
|
1219
|
+
if (body.status === 'published' && !existingItem.publishedAt) {
|
|
1220
|
+
updateExpressionParts.push('publishedAt = :publishedAt');
|
|
1221
|
+
expressionAttributeValues[':publishedAt'] = now;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const result = await client.send(
|
|
1226
|
+
new UpdateCommand({
|
|
1227
|
+
TableName: TABLE_NAME,
|
|
1228
|
+
Key: keys,
|
|
1229
|
+
UpdateExpression: \`SET \${updateExpressionParts.join(', ')}\`,
|
|
1230
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
1231
|
+
ExpressionAttributeValues: expressionAttributeValues,
|
|
1232
|
+
ReturnValues: 'ALL_NEW',
|
|
1233
|
+
})
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
return success(mapToContentItem(result.Attributes!));
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* DELETE /content/:id - Delete content.
|
|
1241
|
+
*/
|
|
1242
|
+
export const remove: APIGatewayProxyHandler = async (event) => {
|
|
1243
|
+
const id = requirePathParam(event, 'id');
|
|
1244
|
+
const keys = contentKeys.keys(id);
|
|
1245
|
+
|
|
1246
|
+
const existingResult = await client.send(
|
|
1247
|
+
new GetCommand({
|
|
1248
|
+
TableName: TABLE_NAME,
|
|
1249
|
+
Key: keys,
|
|
1250
|
+
})
|
|
1251
|
+
);
|
|
1252
|
+
|
|
1253
|
+
if (!existingResult.Item) {
|
|
1254
|
+
return notFound(\`Content with id '\${id}' not found\`);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
await client.send(
|
|
1258
|
+
new DeleteCommand({
|
|
1259
|
+
TableName: TABLE_NAME,
|
|
1260
|
+
Key: keys,
|
|
1261
|
+
})
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
return success({ deleted: true, id });
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Main router handler for content operations.
|
|
1269
|
+
*
|
|
1270
|
+
* Routes requests to the appropriate CRUD function based on HTTP method and path.
|
|
1271
|
+
* This handler is invoked by API Gateway and delegates to the specific operation.
|
|
1272
|
+
*
|
|
1273
|
+
* @example
|
|
1274
|
+
* // API Gateway routes:
|
|
1275
|
+
* // GET /content -> list
|
|
1276
|
+
* // POST /content -> create
|
|
1277
|
+
* // GET /content/{id} -> get
|
|
1278
|
+
* // PUT /content/{id} -> update
|
|
1279
|
+
* // DELETE /content/{id} -> remove
|
|
1280
|
+
* // GET /content/slug/{slug} -> getBySlug
|
|
1281
|
+
*/
|
|
1282
|
+
export const handler: APIGatewayProxyHandler = async (event, context, callback) => {
|
|
1283
|
+
const method = event.httpMethod;
|
|
1284
|
+
const resource = event.resource;
|
|
1285
|
+
|
|
1286
|
+
try {
|
|
1287
|
+
// Route to the appropriate handler based on resource and method
|
|
1288
|
+
let result: Awaited<ReturnType<typeof list>> | undefined;
|
|
1289
|
+
|
|
1290
|
+
switch (resource) {
|
|
1291
|
+
case '/content':
|
|
1292
|
+
if (method === 'GET') result = await list(event, context, callback);
|
|
1293
|
+
else if (method === 'POST') result = await create(event, context, callback);
|
|
1294
|
+
break;
|
|
1295
|
+
case '/content/{id}':
|
|
1296
|
+
if (method === 'GET') result = await get(event, context, callback);
|
|
1297
|
+
else if (method === 'PUT') result = await update(event, context, callback);
|
|
1298
|
+
else if (method === 'DELETE') result = await remove(event, context, callback);
|
|
1299
|
+
break;
|
|
1300
|
+
case '/content/slug/{slug}':
|
|
1301
|
+
if (method === 'GET') result = await getBySlug(event, context, callback);
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
if (result) {
|
|
1306
|
+
return result;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
return {
|
|
1310
|
+
statusCode: 405,
|
|
1311
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1312
|
+
body: JSON.stringify({
|
|
1313
|
+
success: false,
|
|
1314
|
+
error: {
|
|
1315
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
1316
|
+
message: \`Method \${method} not allowed for \${resource}\`,
|
|
1317
|
+
},
|
|
1318
|
+
}),
|
|
1319
|
+
};
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
console.error('Content handler error:', error);
|
|
1322
|
+
return {
|
|
1323
|
+
statusCode: 500,
|
|
1324
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1325
|
+
body: JSON.stringify({
|
|
1326
|
+
success: false,
|
|
1327
|
+
error: {
|
|
1328
|
+
code: 'INTERNAL_ERROR',
|
|
1329
|
+
message: 'Internal server error',
|
|
1330
|
+
},
|
|
1331
|
+
}),
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
`;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Returns the media handler template content.
|
|
1339
|
+
*/
|
|
1340
|
+
getMediaHandlerTemplate() {
|
|
1341
|
+
return `/**
|
|
1342
|
+
* Media upload and management handler.
|
|
1343
|
+
*
|
|
1344
|
+
* Provides endpoints for uploading files to S3 using presigned URLs
|
|
1345
|
+
* and managing media records.
|
|
1346
|
+
*/
|
|
1347
|
+
|
|
1348
|
+
import type { APIGatewayProxyHandler } from 'aws-lambda';
|
|
1349
|
+
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
|
1350
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
1351
|
+
import { success, notFound, created, badRequest } from '../lib/response';
|
|
1352
|
+
import { parseBody, requirePathParam, getQueryParam } from '../lib/request';
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Allowed media MIME types.
|
|
1356
|
+
*/
|
|
1357
|
+
const ALLOWED_MIME_TYPES = [
|
|
1358
|
+
'image/jpeg',
|
|
1359
|
+
'image/png',
|
|
1360
|
+
'image/gif',
|
|
1361
|
+
'image/webp',
|
|
1362
|
+
'image/svg+xml',
|
|
1363
|
+
'application/pdf',
|
|
1364
|
+
'video/mp4',
|
|
1365
|
+
'video/webm',
|
|
1366
|
+
'audio/mpeg',
|
|
1367
|
+
'audio/wav',
|
|
1368
|
+
'audio/ogg',
|
|
1369
|
+
];
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Maximum file size in bytes (50MB default).
|
|
1373
|
+
*/
|
|
1374
|
+
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE || '52428800', 10);
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* S3 bucket name for media storage.
|
|
1378
|
+
*/
|
|
1379
|
+
const BUCKET_NAME = process.env.MEDIA_BUCKET_NAME || '';
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Base URL for media access (CloudFront or S3).
|
|
1383
|
+
*/
|
|
1384
|
+
const MEDIA_BASE_URL = process.env.MEDIA_BASE_URL || '';
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Presigned URL expiration time in seconds.
|
|
1388
|
+
*/
|
|
1389
|
+
const PRESIGNED_URL_EXPIRES_IN = 300;
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* S3 client instance.
|
|
1393
|
+
*/
|
|
1394
|
+
const s3Client = new S3Client({});
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Media item interface.
|
|
1398
|
+
*/
|
|
1399
|
+
interface MediaItem {
|
|
1400
|
+
id: string;
|
|
1401
|
+
key: string;
|
|
1402
|
+
filename: string;
|
|
1403
|
+
mimeType: string;
|
|
1404
|
+
size: number;
|
|
1405
|
+
url: string;
|
|
1406
|
+
uploadedAt: string;
|
|
1407
|
+
uploadedBy?: string;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Paginated response structure.
|
|
1412
|
+
*/
|
|
1413
|
+
interface PaginatedResponse<T> {
|
|
1414
|
+
items: T[];
|
|
1415
|
+
nextCursor?: string;
|
|
1416
|
+
total?: number;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Generates a unique ID for media items.
|
|
1421
|
+
*/
|
|
1422
|
+
function generateId(): string {
|
|
1423
|
+
const timestamp = Date.now().toString(36);
|
|
1424
|
+
const randomPart = Math.random().toString(36).substring(2, 10);
|
|
1425
|
+
return \`\${timestamp}\${randomPart}\`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Extracts file extension from MIME type.
|
|
1430
|
+
*/
|
|
1431
|
+
function getExtensionFromMimeType(mimeType: string): string {
|
|
1432
|
+
const mimeToExt: Record<string, string> = {
|
|
1433
|
+
'image/jpeg': 'jpg',
|
|
1434
|
+
'image/png': 'png',
|
|
1435
|
+
'image/gif': 'gif',
|
|
1436
|
+
'image/webp': 'webp',
|
|
1437
|
+
'image/svg+xml': 'svg',
|
|
1438
|
+
'application/pdf': 'pdf',
|
|
1439
|
+
'video/mp4': 'mp4',
|
|
1440
|
+
'video/webm': 'webm',
|
|
1441
|
+
'audio/mpeg': 'mp3',
|
|
1442
|
+
'audio/wav': 'wav',
|
|
1443
|
+
'audio/ogg': 'ogg',
|
|
1444
|
+
};
|
|
1445
|
+
return mimeToExt[mimeType] || 'bin';
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Validates the content type is allowed.
|
|
1450
|
+
*/
|
|
1451
|
+
function isValidContentType(contentType: string): boolean {
|
|
1452
|
+
return ALLOWED_MIME_TYPES.includes(contentType);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* POST /media/upload-url - Get a presigned URL for direct S3 upload.
|
|
1457
|
+
*/
|
|
1458
|
+
export const getUploadUrl: APIGatewayProxyHandler = async (event) => {
|
|
1459
|
+
const body = parseBody<{
|
|
1460
|
+
filename: string;
|
|
1461
|
+
contentType: string;
|
|
1462
|
+
size: number;
|
|
1463
|
+
}>(event);
|
|
1464
|
+
|
|
1465
|
+
if (!body.filename || !body.contentType || !body.size) {
|
|
1466
|
+
return badRequest('Missing required fields: filename, contentType, size');
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
if (!isValidContentType(body.contentType)) {
|
|
1470
|
+
return badRequest(
|
|
1471
|
+
\`Invalid content type: \${body.contentType}. Allowed types: \${ALLOWED_MIME_TYPES.join(', ')}\`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (body.size > MAX_FILE_SIZE) {
|
|
1476
|
+
return badRequest(
|
|
1477
|
+
\`File size \${body.size} exceeds maximum allowed size of \${MAX_FILE_SIZE} bytes\`
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const id = generateId();
|
|
1482
|
+
const ext = getExtensionFromMimeType(body.contentType);
|
|
1483
|
+
const datePath = new Date().toISOString().slice(0, 7);
|
|
1484
|
+
const key = \`uploads/\${datePath}/\${id}.\${ext}\`;
|
|
1485
|
+
|
|
1486
|
+
const command = new PutObjectCommand({
|
|
1487
|
+
Bucket: BUCKET_NAME,
|
|
1488
|
+
Key: key,
|
|
1489
|
+
ContentType: body.contentType,
|
|
1490
|
+
ContentLength: body.size,
|
|
1491
|
+
Metadata: {
|
|
1492
|
+
'original-filename': encodeURIComponent(body.filename),
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
const uploadUrl = await getSignedUrl(s3Client, command, {
|
|
1497
|
+
expiresIn: PRESIGNED_URL_EXPIRES_IN,
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
return success({
|
|
1501
|
+
uploadUrl,
|
|
1502
|
+
key,
|
|
1503
|
+
id,
|
|
1504
|
+
expiresIn: PRESIGNED_URL_EXPIRES_IN,
|
|
1505
|
+
});
|
|
1506
|
+
};
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* POST /media - Create a media record after successful S3 upload.
|
|
1510
|
+
*/
|
|
1511
|
+
export const create: APIGatewayProxyHandler = async (event) => {
|
|
1512
|
+
const body = parseBody<{
|
|
1513
|
+
id: string;
|
|
1514
|
+
key: string;
|
|
1515
|
+
filename: string;
|
|
1516
|
+
mimeType: string;
|
|
1517
|
+
size: number;
|
|
1518
|
+
}>(event);
|
|
1519
|
+
|
|
1520
|
+
if (!body.id || !body.key || !body.filename || !body.mimeType || !body.size) {
|
|
1521
|
+
return badRequest('Missing required fields: id, key, filename, mimeType, size');
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const url = MEDIA_BASE_URL
|
|
1525
|
+
? \`\${MEDIA_BASE_URL}/\${body.key}\`
|
|
1526
|
+
: \`https://\${BUCKET_NAME}.s3.amazonaws.com/\${body.key}\`;
|
|
1527
|
+
|
|
1528
|
+
const media: MediaItem = {
|
|
1529
|
+
id: body.id,
|
|
1530
|
+
key: body.key,
|
|
1531
|
+
filename: body.filename,
|
|
1532
|
+
mimeType: body.mimeType,
|
|
1533
|
+
size: body.size,
|
|
1534
|
+
url,
|
|
1535
|
+
uploadedAt: new Date().toISOString(),
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
return created(media);
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* GET /media - List media items with pagination.
|
|
1543
|
+
*/
|
|
1544
|
+
export const list: APIGatewayProxyHandler = async (event) => {
|
|
1545
|
+
const _limit = parseInt(getQueryParam(event, 'limit', '20') ?? '20', 10);
|
|
1546
|
+
const _cursor = getQueryParam(event, 'cursor');
|
|
1547
|
+
const _type = getQueryParam(event, 'type');
|
|
1548
|
+
|
|
1549
|
+
// TODO: Implement with DynamoDB
|
|
1550
|
+
const items: MediaItem[] = [];
|
|
1551
|
+
const response: PaginatedResponse<MediaItem> = {
|
|
1552
|
+
items,
|
|
1553
|
+
nextCursor: undefined,
|
|
1554
|
+
total: 0,
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
return success(response);
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* GET /media/:id - Get a single media item.
|
|
1562
|
+
*/
|
|
1563
|
+
export const get: APIGatewayProxyHandler = async (event) => {
|
|
1564
|
+
const id = requirePathParam(event, 'id');
|
|
1565
|
+
|
|
1566
|
+
// TODO: Implement with DynamoDB
|
|
1567
|
+
return notFound(\`Media with id '\${id}' not found\`);
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* DELETE /media/:id - Delete a media item.
|
|
1572
|
+
*/
|
|
1573
|
+
export const remove: APIGatewayProxyHandler = async (event) => {
|
|
1574
|
+
const id = requirePathParam(event, 'id');
|
|
1575
|
+
|
|
1576
|
+
// TODO: Implement with DynamoDB and S3 deletion
|
|
1577
|
+
return success({ deleted: true, id });
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Main router handler for media operations.
|
|
1582
|
+
*/
|
|
1583
|
+
export const handler: APIGatewayProxyHandler = async (event, context, callback) => {
|
|
1584
|
+
const method = event.httpMethod;
|
|
1585
|
+
const resource = event.resource;
|
|
1586
|
+
|
|
1587
|
+
try {
|
|
1588
|
+
let result: Awaited<ReturnType<typeof list>> | undefined;
|
|
1589
|
+
|
|
1590
|
+
switch (resource) {
|
|
1591
|
+
case '/media':
|
|
1592
|
+
if (method === 'GET') result = await list(event, context, callback);
|
|
1593
|
+
else if (method === 'POST') result = await create(event, context, callback);
|
|
1594
|
+
break;
|
|
1595
|
+
case '/media/upload-url':
|
|
1596
|
+
if (method === 'POST') result = await getUploadUrl(event, context, callback);
|
|
1597
|
+
break;
|
|
1598
|
+
case '/media/{id}':
|
|
1599
|
+
if (method === 'GET') result = await get(event, context, callback);
|
|
1600
|
+
else if (method === 'DELETE') result = await remove(event, context, callback);
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (result) {
|
|
1605
|
+
return result;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
return {
|
|
1609
|
+
statusCode: 405,
|
|
1610
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1611
|
+
body: JSON.stringify({
|
|
1612
|
+
success: false,
|
|
1613
|
+
error: {
|
|
1614
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
1615
|
+
message: \`Method \${method} not allowed for \${resource}\`,
|
|
1616
|
+
},
|
|
1617
|
+
}),
|
|
1618
|
+
};
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
console.error('Media handler error:', error);
|
|
1621
|
+
return {
|
|
1622
|
+
statusCode: 500,
|
|
1623
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1624
|
+
body: JSON.stringify({
|
|
1625
|
+
success: false,
|
|
1626
|
+
error: {
|
|
1627
|
+
code: 'INTERNAL_ERROR',
|
|
1628
|
+
message: 'Internal server error',
|
|
1629
|
+
},
|
|
1630
|
+
}),
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
664
1634
|
`;
|
|
665
1635
|
}
|
|
666
1636
|
}
|