Centralised ad carousel board — a multi-tenant SaaS platform where store TVs display image slideshows managed from a cloud dashboard.
Super Admin → adds Client companies
Client Admin → adds Branches, uploads images per branch
Store login → opens browser on TV → full-screen carousel auto-plays
Changes made by the Client Admin (new images, reordering, deletions) appear on the TV within 60 seconds automatically.
| Backend | Node.js Lambda functions (plain CommonJS JS) |
| API | AWS HTTP API Gateway |
| Auth | Amazon Cognito — 1 user pool, 3 groups (SUPER_ADMIN / CLIENT_ADMIN / STORE) |
| Database | DynamoDB on-demand |
| File storage | S3 + CloudFront CDN |
| Infrastructure | AWS SAM (CloudFormation + Transform: AWS::Serverless-2016-10-31) |
| CI/CD | Bitbucket Pipelines (pipeline YAML separate) |
| Frontend | React + Vite + Amplify — not built yet (Figma designs pending) |
ammas/
│
├── packages/@ammas/ # Shared reusable packages (used by all Lambdas)
│ ├── db/index.js # DynamoDB client singleton + key builders
│ ├── auth/index.js # resolveUser(), assertClientAccess(), assertBranchAccess()
│ └── response/index.js # ok(), created(), forbidden(), badRequest(), etc.
│
├── lambdas/ # One folder per Lambda — each has index.js + template.yaml
│ ├── auth-post-confirm/ # Cognito PostConfirmation trigger → writes user to DDB
│ ├── admin-create-client/ # POST /admin/clients
│ ├── admin-list-clients/ # GET /admin/clients
│ ├── admin-update-client/ # PUT /admin/clients/{clientId}
│ ├── admin-delete-client/ # DELETE /admin/clients/{clientId}
│ ├── branches-create/ # POST /clients/{clientId}/branches
│ ├── branches-list/ # GET /clients/{clientId}/branches
│ ├── branches-update/ # PUT /clients/{clientId}/branches/{branchId}
│ ├── media-list/ # GET …/media
│ ├── media-upload-url/ # POST …/media/upload-url (returns presigned S3 PUT URL)
│ ├── media-delete/ # DELETE …/media/{mediaId}
│ ├── media-reorder/ # PUT …/media/reorder
│ ├── media-processor/ # S3 trigger → Sharp thumbnail → DDB record
│ └── display-get-carousel/ # GET /display/{branchId}/carousel (TV polling endpoint)
│
├── infrastructure/ # Shared CloudFormation templates (deployed via root template)
│ ├── cognito.yaml # User pool, 3 groups, 2 app clients
│ ├── dynamodb.yaml # ammas-main + ammas-events tables + GSIs
│ ├── s3-storage.yaml # S3 bucket + CloudFront CDN (OAC)
│ └── amplify.yaml # Amplify app shell (FE wired later)
│
├── layers/sharp/ # Lambda Layer — Sharp image library compiled for Linux
│ ├── build.sh # Run once: installs Sharp for Linux x64
│ └── template.yaml
│
├── template.yaml # ROOT SAM template — composes all nested stacks
├── package.json # All Lambda dependencies live here (single root package.json)
└── .npmrc # hoist=true — packages available in root node_modules
Lambdas don't have their own package.json. All dependencies (@aws-sdk/*, uuid, @ammas/*) are declared at the root. hoist=true in .npmrc puts everything into root node_modules/. SAM's esbuild runs from the root (CodeUri: ./) and bundles each Lambda individually using its entry point (lambdas/<name>/index.js).
| Method | Path | Lambda |
|---|---|---|
| POST | /admin/clients |
admin-create-client |
| GET | /admin/clients |
admin-list-clients |
| PUT | /admin/clients/{clientId} |
admin-update-client |
| DELETE | /admin/clients/{clientId} |
admin-delete-client |
| POST | /clients/{clientId}/branches |
branches-create |
| GET | /clients/{clientId}/branches |
branches-list |
| PUT | /clients/{clientId}/branches/{branchId} |
branches-update |
| GET | /clients/{clientId}/branches/{branchId}/media |
media-list |
| POST | /clients/{clientId}/branches/{branchId}/media/upload-url |
media-upload-url |
| DELETE | /clients/{clientId}/branches/{branchId}/media/{mediaId} |
media-delete |
| PUT | /clients/{clientId}/branches/{branchId}/media/reorder |
media-reorder |
| GET | /display/{branchId}/carousel |
display-get-carousel |
All routes require a Cognito JWT in the Authorization header. The API Gateway validates the token before the Lambda runs.
SAM is not a separate tool — template.yaml is a CloudFormation template with Transform: AWS::Serverless-2016-10-31. sam build bundles the code; sam deploy runs aws cloudformation deploy.
template.yaml (root)
├── infrastructure/cognito.yaml → Cognito User Pool + Groups + App Clients
├── infrastructure/dynamodb.yaml → DynamoDB Tables
├── infrastructure/s3-storage.yaml → S3 Bucket + CloudFront Distribution
├── layers/sharp/template.yaml → Sharp Lambda Layer
├── lambdas/*/template.yaml × 14 → one Lambda + IAM policy per file
└── AWS::Serverless::HttpApi → shared API Gateway (defined inline in root)
Each Lambda template registers its own API route via the Events.Api property. The shared HTTP API Gateway ID is passed as a parameter from the root template.
Single-table design — all entity types share one table.
| Entity | PK | SK |
|---|---|---|
| Client | CLIENT#<id> |
#METADATA |
| Branch | CLIENT#<clientId> |
BRANCH#<branchId> |
| User | USER#<cognitoSub> |
#METADATA |
| Carousel config | BRANCH#<branchId> |
CAROUSEL#CONFIG |
| Media item | BRANCH#<branchId> |
MEDIA#<mediaId> |
GSIs: GSI-EntityType (list all clients), GSI-UserByCognito (auth guard lookup), GSI-UserByEmail
Every Lambda calls resolveUser(event) from @ammas/auth. It extracts the sub claim from the JWT, looks up the user record via GSI-UserByCognito, then asserts the user's clientId/branchId matches the path parameter. A mismatch returns 403 — one client can never touch another's data.
- Client Admin calls
POST …/media/upload-url→ Lambda returns a presigned S3 PUT URL (valid 5 min) - Browser uploads file directly to S3 — no bytes pass through Lambda
- S3 fires
ObjectCreatedevent →media-processorLambda runs media-processorgenerates a 320×180 thumbnail via Sharp (Lambda Layer), writes DDB record, bumpscarousel.updatedAt- Store TV polls
/display/{branchId}/carouselevery 60s — detectslastUpdatedAtchanged → reloads slides
The carousel polling endpoint returns Cache-Control: max-age=55 so CloudFront caches it. 100 stores polling every 60s = ~1 Lambda call/min instead of 100.
# 1. Install dependencies
pnpm install
# 2. Build Sharp layer for Linux (run once, requires Node + npm)
cd layers/sharp && bash build.sh && cd ../..
# 3. Bundle all Lambda functions
sam build
# 4. Deploy to staging
sam deploy \
--stack-name ammas-banner-staging \
--region ap-south-1 \
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \
--parameter-overrides Environment=staging \
--resolve-s3 \
--no-confirm-changeset
# The deploy prints stack outputs:
# ApiUrl, UserPoolId, AdminClientId, StoreClientId, MediaCdnDomainAfter first deploy:
- Go to AWS Console → Cognito →
ammas-banner-staging - Create a user with your email → add to
SUPER_ADMINgroup - Use the
ApiUrloutput as the base URL for the frontend
- Create
lambdas/<name>/index.js— export ahandlerfunction - Create
lambdas/<name>/template.yaml— follow the pattern in any existing template - Add a nested stack entry in
template.yaml(root), passing the required parameters sam build && sam deploy
No package.json or tsconfig.json needed in the Lambda folder.