Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,273 changes: 1,272 additions & 1 deletion app/node/package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions app/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"description": "Node.js ",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js"
"type": "module",
"main": "index.js",
"dependencies": {
"@aws-sdk/client-dynamodb": "3.913.0",
"@aws-sdk/lib-dynamodb": "3.913.0"
}
}
6 changes: 6 additions & 0 deletions app/node/src/error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class AppError extends Error {
constructor(status = 500, message = 'Internal server error'){
super(message)
this.status = status
}
}
70 changes: 70 additions & 0 deletions app/node/src/getHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@

import { QueryCommand } from '@aws-sdk/lib-dynamodb'
import { ddb, TABLE_NAME } from './utils.js'
import { AppError } from './error.js'

export const getUserSubscription = async(userId, subscriptionId = null, includeInactive = false)=>{
const subscriptionQueryParams = {
TableName: TABLE_NAME,
KeyConditionExpression: "pk = :pk AND begins_with(sk, :skPrefix)",
ExpressionAttributeValues:{
":pk": `user_${userId}`,
":skPrefix": `sub_`
}
}

if(!includeInactive){
subscriptionQueryParams['FilterExpression'] = "#st = :active"
subscriptionQueryParams['ExpressionAttributeNames'] = {"#st": "status"}
subscriptionQueryParams.ExpressionAttributeValues[":active"] = 'ACTIVE'
}

const subscriptionResponse = await ddb.send(new QueryCommand(subscriptionQueryParams))
if(!subscriptionResponse.Items.length){
return {}
}

const subscription = subscriptionResponse.Items[0]
if(subscriptionId & subscriptionId !== subscription.pk){
throw new AppError(400, 'User subscription mismatch')
}

const plan = await getPlan(subscription.planSku)
const response = {
userId,
subscriptionId: subscription.pk,
plan:{
sku: subscription.planSku,
name: plan.name,
price: plan.price,
currency: plan.currency,
billingCycle: plan.billingCycle,
features: plan.features
},
startDate: subscription.startDate,
expiresAt: subscription.expiresAt,
canceledAt: subscription.canceledAt,
status: subscription.status,
attributes: subscription.attributes
}
return response
}

export const getPlan = async (planSku)=>{
const planQueryParams = {
TableName: TABLE_NAME,
KeyConditionExpression: "pk = :pk",
FilterExpression: "#st = :status",
ExpressionAttributeNames: {"#st": "status"},
ExpressionAttributeValues:{
":pk": `${planSku}`,
":status": 'ACTIVE'
}
}
const planResponse = await ddb.send(new QueryCommand(planQueryParams))
if(!planResponse.Items.length){
return {}
}
const plan = planResponse.Items[0]
return plan
}
17 changes: 17 additions & 0 deletions app/node/src/httpWrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AppError } from "./error.js";
import { createResponse } from "./utils.js";
export const httpWrapper = (handlerFunction)=>{
return async (event)=>{
try{
const result = await handlerFunction(event)
return createResponse(200, result)
}catch(error){
console.log(error)
if(error instanceof(AppError)){
return createResponse(error.status, {message: error.message}, )
}else{
return createResponse(500, {message: error.message})
}
}
}
}
45 changes: 38 additions & 7 deletions app/node/src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
exports.handler = async (event, context) => {
const response = {
statusCode: 200,
body: JSON.stringify('Hello from Node.js Lambda!'),
};
return response;
};

import { AppError } from './error.js';
import { getUserSubscription } from './getHandler.js'
import { httpWrapper } from './httpWrapper.js';
import { postHandler } from './postHandler.js'
const router = async (event) => {

const method = (event.httpMethod || event.requestContext?.http?.method || "").toUpperCase();
//routes
switch(method){
case 'GET':{
const userId = event?.pathParameters?.userId
if(!userId){
throw new AppError(400, 'Missing UserId!')
}
//controller
const response = await getUserSubscription(userId)
return response
}
case 'POST':{
const raw = event?.body ?? '';
const isB64 = !!event?.isBase64Encoded;
const text = isB64 ? Buffer.from(raw, 'base64').toString('utf8') : raw;

const parsedBody = JSON.parse(text || '{}')
if(!parsedBody){
throw new AppError(400, 'Missing body!')
}
//controller
const result = await postHandler({eventType: parsedBody.eventType, parsedBody})
return result
}
default:
throw new AppError(405, 'Method not allowed!')
}
};

export const handler = httpWrapper(router)
149 changes: 149 additions & 0 deletions app/node/src/postHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@

import { PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'
import { ddb, TABLE_NAME, createResponse, billingCycles } from './utils.js'
import { getPlan, getUserSubscription } from './getHandler.js'
import { AppError } from './error.js'

const SubEvents = Object.freeze({
CREATED: 'subscription.created',
RENEWED: 'subscription.renewed',
CANCELED: 'subscription.cancelled'
})

const createSubscription = async (body)=>{
const { userId, subscriptionId, expiresAt, metadata, timestamp } = body
if( !userId || !subscriptionId || !expiresAt || !metadata || !timestamp ){
throw new AppError(400, `Missing required fields`)
}
if( !metadata.planSku ){
throw new AppError(400, `Missing metadata fields`)
}

const { planSku } = metadata

//validate user has no subscription
const subscription = await getUserSubscription(userId)

if(Object.keys(subscription).length > 0){
throw new AppError(400, 'User has an active subscription')
}

//validate requested plan is not inactive
const plan = await getPlan(planSku)
if(Object.keys(plan).length === 0){
throw new AppError(400, `Requested plan don't exist or is inactive`)
}

//create item
const newSubscription = {
pk: `user_${userId}`,
sk: `${subscriptionId}`,
type: 'sub',
planSku,
startDate: timestamp,
expiresAt,
canceledAt: '',
lastModifiedAt: new Date().toISOString(),
attributes: metadata,
status: 'ACTIVE'
}
await ddb.send(new PutCommand({
TableName: TABLE_NAME,
Item: newSubscription
}))
return newSubscription
}

const cancelSubscription = async(body)=>{
const { userId, subscriptionId, expiresAt } = body

if(!userId || !subscriptionId){
throw new AppError(400, 'Missing required fields')
}

//validate subscription exists
const subscription = await getUserSubscription(userId, subscriptionId)
if(Object.keys(subscription).length === 0){
throw new AppError(400, 'User has no active subscription')
}

//update subscription status
const now = new Date()
const expireDate = new Date(expiresAt)
const updateParams = {
TableName: TABLE_NAME,
Key: { pk: `user_${userId}`, sk: subscriptionId },
UpdateExpression: `
SET canceledAt = :canceledAt,
lastModifiedAt = :lastModifiedAt,
#st = :status
`,
ExpressionAttributeNames: {"#st" : "status"},
ExpressionAttributeValues: {
":canceledAt": now.toISOString(),
":lastModifiedAt": now.toISOString(),
":status": now < expireDate ? 'PENDING' : 'CANCELED'
},
ConditionExpression: "attribute_exists(pk) AND attribute_exists(sk)",
ReturnValues: "ALL_NEW"
}
const response = await ddb.send(new UpdateCommand(updateParams))
return response
}

const renewSubscription = async(body) =>{
const { userId, subscriptionId, expiresAt } = body

//validate subscription exists
if(!userId || !subscriptionId){
return createResponse(400, {message: 'Missing required fields'})
}

const subscription = await getUserSubscription(userId, subscriptionId, true)

if(Object.keys(subscription).length === 0){
throw new AppError(400, 'Subscription not found')
}

//update subscription status
const now = new Date().toISOString()

const updateParams = {
TableName: TABLE_NAME,
Key: { pk: `user_${userId}`, sk: subscriptionId },
UpdateExpression: `
SET lastModifiedAt = :lastModifiedAt,
expiresAt = :expiresAt,
canceledAt = :canceledAt,
#st = :status
`,
ExpressionAttributeNames: {"#st" : "status"},
ExpressionAttributeValues: {
":lastModifiedAt": now,
":expiresAt": expiresAt,
":status": 'ACTIVE',
":canceledAt": ''
},
ConditionExpression: "attribute_exists(pk) AND attribute_exists(sk)",
ReturnValues: "ALL_NEW"
}

const response = await ddb.send(new UpdateCommand(updateParams))
return response

}
export const postHandler = async({ eventType, parsedBody })=>{
switch(eventType){
case SubEvents.CREATED:
const createdResponse = await createSubscription(parsedBody)
return createdResponse
case SubEvents.CANCELED:
const canceledResponse = await cancelSubscription(parsedBody)
return canceledResponse.Attributes
case SubEvents.RENEWED:
const renewedResponse = await renewSubscription(parsedBody)
return renewedResponse.Attributes
default:
throw new AppError(400, 'Invalid post event')
}
}
22 changes: 22 additions & 0 deletions app/node/src/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'

const config = {
region: process.env.REGION,
}
const client = new DynamoDBClient(config)
export const createResponse = (statusCode, body) => {
return {
statusCode, body: JSON.stringify(body), headers: { "Content-Type": "application/json" },
}
}

export const ddb = DynamoDBDocumentClient.from(client, {marshallOptions: { convertClassInstanceToMap: true, removeUndefinedValues: true}})

export const TABLE_NAME = process.env.TABLE_NAME

export const billingCycles = Object.freeze({
MONTHLY: 'MONTHLY',
YEARLY: 'YEARLY'
})
Loading