System Overview
Architecture Flow
ClientLogin Page
→
AuthCognito
→
GatewayAPI Gateway
→
ComputeLambda
→
ClientDashboard
→
DatabaseDynamoDB
Blockchain-style immutable hash chain for grain commodity ownership tracking
Function Index
| Function | Category | Method | Endpoint | Description |
|---|---|---|---|---|
| grainApp-create-title | Core | POST |
/titles |
Creates new grain title with initial cryptographic hash |
| grainApp-transfer-title | Core | POST |
/transfer-title |
Transfers ownership and creates new blockchain record |
| relist_title | Core | POST |
/relist-title |
Relists a transferred title for sale at a new price |
| grainApp-validate-hash | Core | POST |
/validate-hash |
Validates hash chain integrity for tamper detection |
| grainApp-login | Auth | GET |
/ |
Entry point that redirects to Cognito login |
| grainApp-callback | Auth | GET |
/callback |
OAuth callback that exchanges code for tokens |
| grainApp-sales | Query | GET |
/sales |
Returns all titles currently for sale in the marketplace |
| get_my_titles | Query | GET |
/my-titles |
Returns all titles owned by the authenticated user |
| grainApp-get-ownership-history | Query | POST |
/ownership-history |
Returns complete ownership chain for a title |
| grainApp-dashboard | Query | GET |
/dashboard |
Serves the main application dashboard HTML |
| GetAdminStats | Admin | GET |
/admin-stats |
Returns system-wide statistics for administrators |
| grainApp-backup | Utility | - |
EventBridge/CloudWatch |
Backs up DynamoDB table to S3 |
Core Functions
grainApp-create-title
Creates new grain title with initial cryptographic hash
Detailed Operation
This function serves as the genesis point for all grain titles in the system. When a user wants to list grain for sale, this function creates the initial record that will form the foundation of an immutable ownership chain.
Authentication & Security:
The function extracts user identity from Cognito JWT claims in the request context. The seller identity is FORCED to be the authenticated user - the function explicitly ignores any seller_id passed in the request body. This prevents impersonation attacks where someone might try to create listings on behalf of another user.
Input Validation:
Three fields are required: grain_type (Corn, Wheat, Soybean, or Oats), quantity (positive integer representing bushels), and price (decimal value per bushel). The price is normalized to exactly 2 decimal places to ensure consistent hash generation.
Hash Generation Process:
The initial hash is created using SHA-256 with the following concatenated string:
This hash serves multiple purposes: it becomes the primary key (TitleHash), the chain identifier (InitialHash), and the starting point for the hash chain. All future transfers will reference this InitialHash to identify records belonging to the same title.
Record Structure:
The DynamoDB item includes blockchain-style fields: TitleHash (primary key), InitialHash (chain ID), CurrentHash, PreviousHash, HashChain (array of all hashes), plus business fields like GrainType, Quantity, Price, SellerID, Status ('ForSale'), and TransferCount (0 for new titles).
Response:
Returns the generated hash which serves as the unique title identifier. The frontend uses this to display the listing and enable purchases.
Authentication & Security:
The function extracts user identity from Cognito JWT claims in the request context. The seller identity is FORCED to be the authenticated user - the function explicitly ignores any seller_id passed in the request body. This prevents impersonation attacks where someone might try to create listings on behalf of another user.
Input Validation:
Three fields are required: grain_type (Corn, Wheat, Soybean, or Oats), quantity (positive integer representing bushels), and price (decimal value per bushel). The price is normalized to exactly 2 decimal places to ensure consistent hash generation.
Hash Generation Process:
The initial hash is created using SHA-256 with the following concatenated string:
hash_input = f"{grain_type}{quantity}{seller_id}{price_normalized}{timestamp_iso}"This hash serves multiple purposes: it becomes the primary key (TitleHash), the chain identifier (InitialHash), and the starting point for the hash chain. All future transfers will reference this InitialHash to identify records belonging to the same title.
Record Structure:
The DynamoDB item includes blockchain-style fields: TitleHash (primary key), InitialHash (chain ID), CurrentHash, PreviousHash, HashChain (array of all hashes), plus business fields like GrainType, Quantity, Price, SellerID, Status ('ForSale'), and TransferCount (0 for new titles).
Response:
Returns the generated hash which serves as the unique title identifier. The frontend uses this to display the listing and enable purchases.
📄 grainApp-create-title.py
import json import boto3 import hashlib from datetime import datetime from decimal import Decimal dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') def get_user_info(event): """Extract user information from Cognito authorizer claims""" try: claims = event.get('requestContext', {}).get('authorizer', {}).get('claims', {}) if not claims: print("WARNING: No Cognito claims found in request") return None email = claims.get('email', 'unknown@example.com') username = claims.get('cognito:username', 'unknown') user_sub = claims.get('sub', '') groups_claim = claims.get('cognito:groups', '') if isinstance(groups_claim, str): groups = [g.strip() for g in groups_claim.split(',')]if groups_claim else [] else: groups = groups_claim if isinstance(groups_claim, list) else [] is_admin = 'Admin' in groups or 'Admins' in groups role = 'Admin' if is_admin else 'User' user_info = { 'email': email, 'username': username, 'sub': user_sub, 'groups': groups, 'role': role, 'is_admin': is_admin } print(f"User info extracted: {json.dumps(user_info)}") return user_info except Exception as e: print(f"Error extracting user info: {str(e)}") return None def handler(event, context): try: user_info = get_user_info(event) if not user_info: return { 'statusCode': 401, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Unauthorized: Could not extract user information'}) } if isinstance(event.get('body'), str): body = json.loads(event['body']) else: body = event.get('body', event) print(f"Received body: {json.dumps(body)}") print(f"User: {user_info['email']} ({user_info['role']})") seller_id = user_info['email'] seller_name = user_info['username'] seller_sub = user_info['sub'] grain_type = body.get('grain_type') or body.get('GrainType') quantity = body.get('quantity') or body.get('Quantity') price = body.get('price') or body.get('Price') if not grain_type: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing grain_type'}) } if not quantity: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing quantity'}) } if not price: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing price'}) } try: quantity_int = int(quantity) if quantity_int <= 0: raise ValueError("Quantity must be positive") except (ValueError, TypeError) as e: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': f'Invalid quantity: {str(e)}'}) } try: price_float = float(price) if price_float <= 0: raise ValueError("Price must be positive") price_normalized = f"{price_float:.2f}" except (ValueError, TypeError) as e: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': f'Invalid price: {str(e)}'}) } timestamp = int(datetime.utcnow().timestamp()) timestamp_iso = datetime.utcnow().isoformat() hash_input = f"{grain_type}{quantity_int}{seller_id}{price_normalized}{timestamp_iso}" initial_hash = hashlib.sha256(hash_input.encode()).hexdigest() print(f"Hash input: {hash_input}") print(f"Generated hash: {initial_hash}") current_hash = initial_hash previous_hash = initial_hash item = { 'TitleHash': current_hash, 'SellerID': seller_id, 'SellerName': seller_name, 'SellerSub': seller_sub, 'BuyerID': 'NONE', 'BuyerName': 'NONE', 'BuyerSub': 'NONE', 'GrainType': grain_type, 'Quantity': quantity_int, 'Price': Decimal(price_normalized), 'PriceString': price_normalized, 'Status': 'ForSale', 'Timestamp': timestamp, 'TimestampISO': timestamp_iso, 'HashTimestamp': timestamp_iso, 'InitialHash': initial_hash, 'CurrentHash': current_hash, 'PreviousHash': previous_hash, 'TransferCount': 0, 'HashChain': [initial_hash], 'CreatedBy': user_info['email'], 'CreatedByUsername': user_info['username'], 'CreatedBySub': seller_sub } print(f"Saving item to DynamoDB...") table.put_item(Item=item) print(f"Successfully created title with ID: {current_hash}") return { 'statusCode': 200, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'message': 'Title created successfully', 'titleId': current_hash, 'hash': current_hash, 'initial_hash': initial_hash, 'previous_hash': previous_hash, 'status': 'Valid', 'seller_id': seller_id, 'seller_name': seller_name, 'created_by': user_info['email'] }) } except Exception as e: print(f"ERROR in handler: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'error': str(e), 'type': type(e).__name__ }) } lambda_handler = handler
grainApp-transfer-title
Transfers ownership and creates new blockchain record
Detailed Operation
This is the core transaction function that implements blockchain-style ownership transfer. Rather than simply updating a record, it creates a NEW record with a new hash, preserving the complete history.
Authentication & Security:
Like create-title, the buyer identity is extracted from Cognito claims and cannot be spoofed. The function also prevents self-purchase by comparing the buyer's email against the current SellerID.
Pre-Transfer Validation:
The function performs several checks: (1) Title must exist, (2) Status must be 'ForSale', (3) Buyer cannot be the same as seller. It uses ConsistentRead=True to ensure it's working with the latest data.
Hash Chain Integrity:
The new hash incorporates the previous hash, creating an unbreakable chain:
If anyone tampers with historical records, the hash chain breaks and validation fails.
Two-Step Database Update:
Step 1: The OLD record is updated - Status changes to 'Transferred' and BuyerID is set to the buyer's email. This record remains in the database as historical evidence.
Step 2: A NEW record is created with: new TitleHash (the calculated hash), same InitialHash (maintains chain identity), new CurrentHash, PreviousHash pointing to the old record, incremented TransferCount, and crucially - the buyer becomes the new SellerID (they now own it).
Ownership Model:
In this system, SellerID doesn't mean "person selling" - it means "current owner." When you buy a title, you become the SellerID of the new record. The new record has Status='Transferred' (not for sale) until the owner explicitly relists it.
Response:
Returns the new hash, previous hash, and transfer count for the frontend to update its display.
Authentication & Security:
Like create-title, the buyer identity is extracted from Cognito claims and cannot be spoofed. The function also prevents self-purchase by comparing the buyer's email against the current SellerID.
Pre-Transfer Validation:
The function performs several checks: (1) Title must exist, (2) Status must be 'ForSale', (3) Buyer cannot be the same as seller. It uses ConsistentRead=True to ensure it's working with the latest data.
Hash Chain Integrity:
The new hash incorporates the previous hash, creating an unbreakable chain:
hash_input = f"{current_hash}{grain_type}{quantity}{buyer_id}{price_string}{timestamp_iso}{new_transfer_count}"If anyone tampers with historical records, the hash chain breaks and validation fails.
Two-Step Database Update:
Step 1: The OLD record is updated - Status changes to 'Transferred' and BuyerID is set to the buyer's email. This record remains in the database as historical evidence.
Step 2: A NEW record is created with: new TitleHash (the calculated hash), same InitialHash (maintains chain identity), new CurrentHash, PreviousHash pointing to the old record, incremented TransferCount, and crucially - the buyer becomes the new SellerID (they now own it).
Ownership Model:
In this system, SellerID doesn't mean "person selling" - it means "current owner." When you buy a title, you become the SellerID of the new record. The new record has Status='Transferred' (not for sale) until the owner explicitly relists it.
Response:
Returns the new hash, previous hash, and transfer count for the frontend to update its display.
📄 grainApp-transfer-title.py
import json import boto3 import hashlib from datetime import datetime from decimal import Decimal dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') def decimal_default(obj): if isinstance(obj, Decimal): return float(obj) raise TypeError def get_user_info(event): """Extract user information from Cognito authorizer claims""" try: claims = event.get('requestContext', {}).get('authorizer', {}).get('claims', {}) if not claims: print("WARNING: No Cognito claims found inrequest") return None email = claims.get('email', 'unknown@example.com') username = claims.get('cognito:username', 'unknown') user_sub = claims.get('sub', '') groups_claim = claims.get('cognito:groups', '') if isinstance(groups_claim, str): groups = [g.strip() for g in groups_claim.split(',')] if groups_claim else [] else: groups = groups_claim if isinstance(groups_claim, list) else [] is_admin = 'Admin' in groups or 'Admins' in groups role = 'Admin' if is_admin else 'User' return { 'email': email, 'username': username, 'sub': user_sub, 'groups': groups, 'role': role, 'is_admin': is_admin } except Exception as e: print(f"Error extracting user info: {str(e)}") return None def lambda_handler(event, context): try: user_info = get_user_info(event) if not user_info: return { 'statusCode': 401, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Unauthorized: Could not extract user information'}) } if isinstance(event.get('body'), str): body = json.loads(event['body']) else: body = event.get('body', event) print(f"Transfer request from: {user_info['email']}") print(f"Request body: {json.dumps(body)}") title_hash = body.get('title_hash') or body.get('TitleHash') or body.get('titleHash') if not title_hash: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing title_hash'}) } buyer_id = user_info['email'] buyer_name = user_info['username'] buyer_sub = user_info['sub'] response = table.get_item(Key={'TitleHash': title_hash}, ConsistentRead=True) if 'Item' not in response: return { 'statusCode': 404, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Title not found'}) } current_title = response['Item'] if current_title.get('Status') != 'ForSale': return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Title is not available for purchase'}) } current_seller_id = current_title.get('SellerID', '') if buyer_id.lower() == current_seller_id.lower(): return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'You cannot purchase your own title'}) } grain_type = current_title.get('GrainType') quantity = int(current_title.get('Quantity', 0)) current_price = float(current_title.get('Price', 0)) price_string = current_title.get('PriceString', f"{current_price:.2f}") initial_hash = current_title.get('InitialHash', title_hash) current_hash = current_title.get('CurrentHash', title_hash) hash_chain = current_title.get('HashChain', [title_hash]) current_transfer_count = int(current_title.get('TransferCount', 0)) new_transfer_count = current_transfer_count + 1 timestamp = int(datetime.utcnow().timestamp()) timestamp_iso = datetime.utcnow().isoformat() hash_input = f"{current_hash}{grain_type}{quantity}{buyer_id}{price_string}{timestamp_iso}{new_transfer_count}" new_hash = hashlib.sha256(hash_input.encode()).hexdigest() print(f"New hash input: {hash_input}") print(f"New hash: {new_hash}") print(f"Transfer count: {new_transfer_count}") new_hash_chain = hash_chain + [new_hash] table.update_item( Key={'TitleHash': title_hash}, UpdateExpression='''SET #status = :status, BuyerID = :buyer_id, BuyerName = :buyer_name, BuyerSub = :buyer_sub, LastTransferTimestamp = :timestamp, LastTransferTimestampISO = :timestamp_iso ''', ExpressionAttributeNames={ '#status': 'Status' }, ExpressionAttributeValues={ ':status': 'Transferred', ':buyer_id': buyer_id, ':buyer_name': buyer_name, ':buyer_sub': buyer_sub, ':timestamp': timestamp, ':timestamp_iso': timestamp_iso } ) new_item = { 'TitleHash': new_hash, 'InitialHash': initial_hash, 'CurrentHash': new_hash, 'PreviousHash': current_hash, 'HashChain': new_hash_chain, 'GrainType': grain_type, 'Quantity': quantity, 'Price': Decimal(price_string), 'PriceString': price_string, 'SellerID': buyer_id, 'SellerName': buyer_name, 'SellerSub': buyer_sub, 'BuyerID': 'NONE', 'BuyerName': 'NONE', 'BuyerSub': 'NONE', 'Status': 'Transferred', 'TransferCount': new_transfer_count, 'HashTimestamp': timestamp_iso, 'Timestamp': timestamp, 'TimestampISO': timestamp_iso, 'LastTransferTimestamp': timestamp, 'LastTransferTimestampISO': timestamp_iso, 'CreatedBy': current_title.get('CreatedBy', current_seller_id), 'CreatedBySub': current_title.get('CreatedBySub', ''), 'CreatedByUsername': current_title.get('CreatedByUsername', ''), 'TransferredBy': current_seller_id, 'TransferredTo': buyer_id } table.put_item(Item=new_item) print(f"Title transferred successfully to {buyer_id}") print(f"Old record {title_hash} marked as Transferred") print(f"New record created with hash {new_hash}") return { 'statusCode': 200, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'message': 'Title transferred successfully', 'old_hash': title_hash, 'new_hash': new_hash, 'previous_hash': current_hash, 'buyer_id': buyer_id, 'seller_id': current_seller_id, 'transfer_count': new_transfer_count, 'new_status': 'Transferred' }, default=decimal_default) } except Exception as e: print(f"ERROR in transfer: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'error': str(e), 'type': type(e).__name__ }) } handler = lambda_handler
relist_title
Relists a transferred title for sale at a new price
Detailed Operation
After purchasing a title, the owner may want to sell it. This function allows them to relist it on the marketplace at a price of their choosing.
Ownership Verification:
The function performs strict ownership checking by comparing the authenticated user's email (lowercase) against the SellerID of the title record. Only the current owner can relist a title.
Status Validation:
Prevents duplicate listings by checking that the current Status is NOT 'ForSale'. If someone tries to relist an already-listed title, they receive an error.
Price Update:
Unlike transfers where price stays the same, relisting allows the owner to set a new price. This enables market dynamics - buy low, sell high. The new price is normalized to 2 decimal places for consistent hashing.
Blockchain Record Creation:
Like transfer, relisting creates a NEW record rather than updating the existing one:
The old record's Status is changed to 'Listed' (historical marker), and a new record is created with Status='ForSale'.
TransferCount Increment:
Even though ownership doesn't change, TransferCount is incremented. This maintains proper chain ordering - each record in a chain has a unique TransferCount, allowing the system to identify the "head" (current state) by finding the highest TransferCount for a given InitialHash.
Chain Head Concept:
The marketplace and my-titles queries find chain heads by grouping records by InitialHash and selecting the one with the highest TransferCount. This is why TransferCount must always increment.
Ownership Verification:
The function performs strict ownership checking by comparing the authenticated user's email (lowercase) against the SellerID of the title record. Only the current owner can relist a title.
Status Validation:
Prevents duplicate listings by checking that the current Status is NOT 'ForSale'. If someone tries to relist an already-listed title, they receive an error.
Price Update:
Unlike transfers where price stays the same, relisting allows the owner to set a new price. This enables market dynamics - buy low, sell high. The new price is normalized to 2 decimal places for consistent hashing.
Blockchain Record Creation:
Like transfer, relisting creates a NEW record rather than updating the existing one:
hash_input = f"{current_hash}{grain_type}{quantity}{user_email}{price_string}{timestamp_iso}{new_transfer_count}"The old record's Status is changed to 'Listed' (historical marker), and a new record is created with Status='ForSale'.
TransferCount Increment:
Even though ownership doesn't change, TransferCount is incremented. This maintains proper chain ordering - each record in a chain has a unique TransferCount, allowing the system to identify the "head" (current state) by finding the highest TransferCount for a given InitialHash.
Chain Head Concept:
The marketplace and my-titles queries find chain heads by grouping records by InitialHash and selecting the one with the highest TransferCount. This is why TransferCount must always increment.
📄 relist_title.py
import json import boto3 import hashlib from datetime import datetime from decimal import Decimal from collections import defaultdict dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') def decimal_default(obj): if isinstance(obj, Decimal): return float(obj) raise TypeError def get_user_info(event): try: claims = event.get('requestContext', {}).get('authorizer', {}).get('claims', {}) if not claims: return None email = claims.get('email', 'unknown@example.com') username = claims.get('cognito:username', 'unknown') user_sub = claims.get('sub', '') groups_claim = claims.get('cognito:groups', '') if isinstance(groups_claim, str): groups = [g.strip() for g in groups_claim.split(',')] if groups_claim else [] else: groups = groups_claim if isinstance(groups_claim, list) else [] is_admin = 'Admin' in groups or 'Admins' in groups return { 'email': email, 'username': username, 'sub': user_sub, 'groups': groups, 'role': 'Admin' if is_admin else 'User', 'is_admin': is_admin } except Exception as e: print(f"Error extracting user info: {str(e)}") return None def lambda_handler(event, context): try: user_info = get_user_info(event) if not user_info: return { 'statusCode': 401, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Unauthorized'}) } user_email = user_info['email'] user_name = user_info['username'] user_sub = user_info['sub'] if isinstance(event.get('body'), str): body = json.loads(event['body']) else: body = event.get('body', event) print(f"Relist request from: {user_email}") title_hash = body.get('title_hash') or body.get('TitleHash') or body.get('titleHash') new_price = body.get('new_price') or body.get('newPrice') or body.get('price') if not title_hash: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing title_hash'}) } if new_price is None: return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Missing new_price'}) } price_float = float(new_price) price_string = f"{price_float:.2f}" response = table.get_item(Key={'TitleHash': title_hash}, ConsistentRead=True) if 'Item' not in response: return { 'statusCode': 404, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Title not found'}) } current_title = response['Item'] seller_id = (current_title.get('SellerID') or '').lower() status = current_title.get('Status', '') if seller_id != user_email.lower(): return { 'statusCode': 403, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'You do not own this title'}) } if status == 'ForSale': return { 'statusCode': 400, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': 'Title is already for sale'}) } grain_type = current_title.get('GrainType') quantity = int(current_title.get('Quantity', 0)) initial_hash = current_title.get('InitialHash', title_hash) current_hash = current_title.get('CurrentHash', title_hash) hash_chain = current_title.get('HashChain', [title_hash]) current_transfer_count = int(current_title.get('TransferCount', 0)) new_transfer_count = current_transfer_count + 1 timestamp = int(datetime.utcnow().timestamp()) timestamp_iso = datetime.utcnow().isoformat() hash_input = f"{current_hash}{grain_type}{quantity}{user_email}{price_string}{timestamp_iso}{new_transfer_count}" new_hash = hashlib.sha256(hash_input.encode()).hexdigest() print(f"Hash input: {hash_input}") print(f"New hash: {new_hash}") print(f"Transfer count: {new_transfer_count}") new_hash_chain = hash_chain + [new_hash] table.update_item( Key={'TitleHash': title_hash}, UpdateExpression='SET #status = :status, RelistedAt = :timestamp, RelistedAtISO = :timestamp_iso', ExpressionAttributeNames={'#status': 'Status'}, ExpressionAttributeValues={ ':status': 'Listed', ':timestamp': timestamp, ':timestamp_iso': timestamp_iso } ) new_item = { 'TitleHash': new_hash, 'InitialHash': initial_hash, 'CurrentHash': new_hash, 'PreviousHash': current_hash, 'HashChain': new_hash_chain, 'GrainType': grain_type, 'Quantity': quantity, 'Price': Decimal(price_string), 'PriceString': price_string, 'SellerID': user_email, 'SellerName': user_name, 'SellerSub': user_sub, 'BuyerID': 'NONE', 'BuyerName': 'NONE', 'BuyerSub': 'NONE', 'Status': 'ForSale', 'TransferCount': new_transfer_count, 'HashTimestamp': timestamp_iso, 'Timestamp': timestamp, 'TimestampISO': timestamp_iso, 'CreatedBy': current_title.get('CreatedBy', user_email), 'CreatedBySub': current_title.get('CreatedBySub', ''), 'CreatedByUsername': current_title.get('CreatedByUsername', ''), 'RelistedBy': user_email, 'RelistedFrom': title_hash } table.put_item(Item=new_item) print(f"Created new ForSale record with hash {new_hash}") return { 'statusCode': 200, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'message': 'Title relisted successfully', 'old_hash': title_hash, 'new_hash': new_hash, 'new_price': price_float, 'transfer_count': new_transfer_count, 'status': 'ForSale' }, default=decimal_default) } except Exception as e: print(f"ERROR: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'error': str(e)}) } handler = lambda_handler
grainApp-validate-hash
Validates hash chain integrity for tamper detection
Detailed Operation
This function is the integrity checker of the entire system. It recalculates what a record's hash SHOULD be based on its stored fields, then compares against the stored hash. Any mismatch indicates tampering.
Three Hash Formats:
The system uses different hash formulas depending on record type:
1. Original Creation (TransferCount=0, RelistCount=0):
This matches what grainApp-create-title generates.
2. Transfer Records:
Includes PreviousHash to chain records together.
3. Relist Records (RelistCount > 0, Status='ForSale'):
Includes relist marker to differentiate from transfers.
Validation Process:
The function fetches the record, determines which hash format applies based on TransferCount/RelistCount/Status, reconstructs the hash input string using stored field values, calculates SHA-256, and compares against CurrentHash.
Tamper Detection:
If someone directly modifies the DynamoDB record (changing quantity, price, seller, etc.), the recalculated hash won't match. The dashboard calls this function for every listing and displays a red "Invalid" badge if validation fails.
Logging:
The function prints the hash input, calculated hash, and stored hash for debugging. This is invaluable when troubleshooting validation failures - you can see exactly which component differs.
Response:
Returns chain_valid (boolean) and validationStatus ('Valid' or 'Invalid') for the frontend.
Three Hash Formats:
The system uses different hash formulas depending on record type:
1. Original Creation (TransferCount=0, RelistCount=0):
hash_input = f"{grain}{qty}{seller_id}{price}{timestamp}"This matches what grainApp-create-title generates.
2. Transfer Records:
hash_input = f"{prev_hash}{grain}{qty}{seller_id}{price}{timestamp}{transfer_count}"Includes PreviousHash to chain records together.
3. Relist Records (RelistCount > 0, Status='ForSale'):
hash_input = f"{prev_hash}{grain}{qty}{seller_id}{price}{timestamp}{transfer_count}R{relist_count}"Includes relist marker to differentiate from transfers.
Validation Process:
The function fetches the record, determines which hash format applies based on TransferCount/RelistCount/Status, reconstructs the hash input string using stored field values, calculates SHA-256, and compares against CurrentHash.
Tamper Detection:
If someone directly modifies the DynamoDB record (changing quantity, price, seller, etc.), the recalculated hash won't match. The dashboard calls this function for every listing and displays a red "Invalid" badge if validation fails.
Logging:
The function prints the hash input, calculated hash, and stored hash for debugging. This is invaluable when troubleshooting validation failures - you can see exactly which component differs.
Response:
Returns chain_valid (boolean) and validationStatus ('Valid' or 'Invalid') for the frontend.
📄 grainApp-validate-hash.py
import json import boto3 import hashlib dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') def validate_single_record(item): """Validate a single record's hash""" grain = item['GrainType'] qty = int(item['Quantity']) price = item['PriceString'] transfer_count = int(item.get('TransferCount', 0)) relist_count = int(item.get('RelistCount', 0)) stored_hash = item['CurrentHash'] seller_id = item.get('SellerID', '') status = item.get('Status', '') if transfer_count == 0 and relist_count == 0: timestamp = item.get('HashTimestamp') or item.get('TimestampISO') hash_input = f"{grain}{qty}{seller_id}{price}{timestamp}" elif relist_count > 0 and status == 'ForSale': prev = item.get('PreviousHash', '') timestamp = item.get('HashTimestamp') or item.get('RelistedAtISO', '') hash_input = f"{prev}{grain}{qty}{seller_id}{price}{timestamp}{transfer_count}R{relist_count}" else: prev = item.get('PreviousHash', '') timestamp = item.get('LastTransferTimestampISO') or item.get('HashTimestamp', '') hash_input = f"{prev}{grain}{qty}{seller_id}{price}{timestamp}{transfer_count}" calculated_hash = hashlib.sha256(hash_input.encode()).hexdigest() print(f"Transfer #{transfer_count}, Relist #{relist_count}, Status: {status}:") print(f" Hash input: {hash_input}") print(f" Calculated: {calculated_hash}") print(f" Stored: {stored_hash}") print(f" Match: {calculated_hash == stored_hash}") return calculated_hash == stored_hash def handler(event, context): try: body = json.loads(event['body']) if isinstance(event.get('body'), str) else event.get('body', event) hash_to_validate = body.get('title_hash') or body.get('TitleHash') if not hash_to_validate: return {'statusCode': 400, 'body': json.dumps({'chain_valid': False})} response = table.get_item(Key={'TitleHash': hash_to_validate}) if 'Item' not in response: scan_response = table.scan( FilterExpression='CurrentHash = :hash', ExpressionAttributeValues={':hash': hash_to_validate} ) items = scan_response.get('Items', []) if not items: return {'statusCode': 404, 'body': json.dumps({'chain_valid': False})} item = items[0] else: item = response['Item'] is_valid = validate_single_record(item) print(f"Final result: {'VALID' if is_valid else 'INVALID'}") return { 'statusCode': 200, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({ 'chain_valid': is_valid, 'isValid': is_valid, 'validationStatus': 'Valid' if is_valid else 'Invalid' }) } except Exception as e: print(f"ERROR: {e}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,POST' }, 'body': json.dumps({'chain_valid': False}) } lambda_handler = handler
Authentication Functions
grainApp-login
Entry point that redirects to Cognito login
Detailed Operation
This is the simplest function in the system - it constructs the Cognito Hosted UI URL and returns an HTML page that automatically redirects to it.
Environment Variables:
Three values come from Lambda environment configuration:
- COGNITO_DOMAIN: The Cognito user pool domain (e.g., 'grain-app.auth.us-east-2.amazoncognito.com')
- CLIENT_ID: The Cognito app client ID
- REDIRECT_URI: Where Cognito should send users after login (the callback Lambda)
OAuth URL Construction:
The response_type=code indicates Authorization Code flow (most secure). Scopes request email, OpenID identity, and profile information.
HTML Response:
Rather than a 302 redirect, the function returns HTML with a meta-refresh tag. This provides a brief "Redirecting to login..." message before the browser navigates to Cognito.
User Experience:
When users visit the application root URL, they see a brief loading message, then the Cognito Hosted UI appears where they can sign in, sign up, or reset their password. After successful authentication, Cognito redirects to the callback URL with an authorization code.
Environment Variables:
Three values come from Lambda environment configuration:
- COGNITO_DOMAIN: The Cognito user pool domain (e.g., 'grain-app.auth.us-east-2.amazoncognito.com')
- CLIENT_ID: The Cognito app client ID
- REDIRECT_URI: Where Cognito should send users after login (the callback Lambda)
OAuth URL Construction:
login_url = f"https://{cognito_domain}/login?client_id={client_id}&response_type=code&scope=email+openid+profile&redirect_uri={redirect_uri}"The response_type=code indicates Authorization Code flow (most secure). Scopes request email, OpenID identity, and profile information.
HTML Response:
Rather than a 302 redirect, the function returns HTML with a meta-refresh tag. This provides a brief "Redirecting to login..." message before the browser navigates to Cognito.
User Experience:
When users visit the application root URL, they see a brief loading message, then the Cognito Hosted UI appears where they can sign in, sign up, or reset their password. After successful authentication, Cognito redirects to the callback URL with an authorization code.
📄 grainApp-login.py
import os def handler(event, context): cognito_domain = os.environ['COGNITO_DOMAIN'] client_id = os.environ['CLIENT_ID'] redirect_uri = os.environ['REDIRECT_URI'] login_url = f"https://{cognito_domain}/login?client_id={client_id}&response_type=code&scope=email+openid+profile&redirect_uri={redirect_uri}" return { 'statusCode': 200, 'headers': {'Content-Type': 'text/html'}, 'body': f'''<!DOCTYPE html> <html> <head> <meta http-equiv="refresh" content="1;url={login_url}"> <title>Redirecting to Login</title> </head> <body style="text-align:center;padding:100px;font-family:Arial;"> <h1>Grain Title Tracking</h1> <p>Redirecting to login...</p> </body> </html>''' }
grainApp-callback
OAuth callback that exchanges code for tokens
Detailed Operation
This function completes the OAuth 2.0 Authorization Code flow by exchanging the authorization code for tokens and establishing the user session.
Authorization Code Extraction:
Cognito redirects here with ?code=XXXXX in the query string. The function extracts this code from event['queryStringParameters'].
Token Exchange:
The function makes a POST request to Cognito's /oauth2/token endpoint with:
- grant_type: 'authorization_code'
- client_id: The app client ID
- code: The authorization code from Cognito
- redirect_uri: Must match exactly what was used in the login request
Cognito responds with id_token (JWT with user info), access_token (for API calls), and refresh_token.
JWT Decoding:
The id_token is a JWT with three parts separated by dots. The function base64-decodes the middle part (payload) to extract user claims like cognito:username and email.
Group Lookup:
The function calls Cognito's admin_list_groups_for_user API to get the user's group memberships. This determines their role in the application.
Role Determination:
Groups are checked in priority order: Admins → Admin role, Sellers → Seller role, Buyers → Buyer role, otherwise User. The Admin role enables the admin statistics button in the dashboard.
Session Establishment:
The function returns HTML with JavaScript that stores tokens in sessionStorage:
- id_token: Used as Authorization header for API calls
- access_token: Available for future use
- user_email: Displayed in the UI
- user_role: Controls UI elements and permissions
Finally, it redirects to /dashboard where the main application loads.
Authorization Code Extraction:
Cognito redirects here with ?code=XXXXX in the query string. The function extracts this code from event['queryStringParameters'].
Token Exchange:
The function makes a POST request to Cognito's /oauth2/token endpoint with:
- grant_type: 'authorization_code'
- client_id: The app client ID
- code: The authorization code from Cognito
- redirect_uri: Must match exactly what was used in the login request
Cognito responds with id_token (JWT with user info), access_token (for API calls), and refresh_token.
JWT Decoding:
The id_token is a JWT with three parts separated by dots. The function base64-decodes the middle part (payload) to extract user claims like cognito:username and email.
Group Lookup:
The function calls Cognito's admin_list_groups_for_user API to get the user's group memberships. This determines their role in the application.
Role Determination:
Groups are checked in priority order: Admins → Admin role, Sellers → Seller role, Buyers → Buyer role, otherwise User. The Admin role enables the admin statistics button in the dashboard.
Session Establishment:
The function returns HTML with JavaScript that stores tokens in sessionStorage:
- id_token: Used as Authorization header for API calls
- access_token: Available for future use
- user_email: Displayed in the UI
- user_role: Controls UI elements and permissions
Finally, it redirects to /dashboard where the main application loads.
📄 grainApp-callback.py
import json import os import urllib.request import urllib.parse import boto3 import base64 def handler(event, context): try: params = event.get('queryStringParameters', {}) code = params.get('code') if not code: return { 'statusCode': 400, 'headers': {'Content-Type': 'text/html'}, 'body': '<h1>Error: No authorization code</h1>' } data = urllib.parse.urlencode({ 'grant_type': 'authorization_code', 'client_id': os.environ['CLIENT_ID'], 'code': code, 'redirect_uri': os.environ['REDIRECT_URI'] }).encode('utf-8') token_url = f"https://{os.environ['COGNITO_DOMAIN']}/oauth2/token" request = urllib.request.Request(token_url, data, method='POST') request.add_header('Content-Type', 'application/x-www-form-urlencoded') with urllib.request.urlopen(request) as response: token_data = json.loads(response.read().decode('utf-8')) id_token = token_data['id_token'] access_token = token_data['access_token'] payload = json.loads(base64.urlsafe_b64decode(id_token.split('.')[1] + '==')) username = payload['cognito:username'] email = payload['email'] cognito = boto3.client('cognito-idp') groups_response = cognito.admin_list_groups_for_user( UserPoolId=os.environ['USER_POOL_ID'], Username=username ) groups = [g['GroupName'] for g in groups_response.get('Groups', [])] if 'Admins' in groups: role = 'Admin' elif 'Sellers' in groups: role = 'Seller' elif 'Buyers' in groups: role = 'Buyer' else: role = 'User' return { 'statusCode': 200, 'headers': {'Content-Type': 'text/html'}, 'body': f'''<!DOCTYPE html> <html> <head> <title>Login Success</title> <script> sessionStorage.setItem('id_token', '{id_token}'); sessionStorage.setItem('access_token', '{access_token}'); sessionStorage.setItem('user_email', '{email}'); sessionStorage.setItem('user_role', '{role}'); window.location.href = '/dashboard'; </script> </head> <body> <p>Login successful. Redirecting...</p> </body> </html>''' } except Exception as ex: print(f"Error: {str(ex)}") return { 'statusCode': 500, 'headers': {'Content-Type': 'application/json'}, 'body': json.dumps({'error': str(ex)}) }
Query Functions
grainApp-sales
Returns all titles currently for sale in the marketplace
Detailed Operation
This function powers the marketplace view by returning all titles that are currently available for purchase.
Full Table Scan:
The function scans the entire GrainTitles table with ConsistentRead=True. While scans are expensive at scale, they're necessary here because we need to group records by InitialHash, which isn't the primary key.
Pagination Handling:
DynamoDB limits scan results to 1MB. The function handles this by checking for LastEvaluatedKey and continuing to scan until all records are retrieved.
Chain Grouping:
Records are grouped into a dictionary keyed by InitialHash. Each grain title (original creation) generates a unique InitialHash, and all subsequent transfers/relists share that InitialHash. This groups all records belonging to the same "title" together.
Finding Chain Heads:
For each InitialHash group, records are sorted by TransferCount in descending order. The first record (highest TransferCount) is the "chain head" - the current state of that title. This is the fundamental query pattern of the blockchain model.
ForSale Filter:
Only chain heads with Status='ForSale' are included in results. Titles that have been purchased but not relisted (Status='Transferred') don't appear in the marketplace.
Response Format:
Returns {items: [...], count: N} where items contains the full DynamoDB records for display. The DecimalEncoder class converts Decimal types to floats for JSON serialization (DynamoDB uses Decimal for numbers).
Frontend Usage:
The dashboard calls this endpoint on load and displays each item as a card showing grain type, quantity, price, seller, and validation status. Users can click "Buy This Title" on any listing they don't own.
Full Table Scan:
The function scans the entire GrainTitles table with ConsistentRead=True. While scans are expensive at scale, they're necessary here because we need to group records by InitialHash, which isn't the primary key.
Pagination Handling:
DynamoDB limits scan results to 1MB. The function handles this by checking for LastEvaluatedKey and continuing to scan until all records are retrieved.
Chain Grouping:
Records are grouped into a dictionary keyed by InitialHash. Each grain title (original creation) generates a unique InitialHash, and all subsequent transfers/relists share that InitialHash. This groups all records belonging to the same "title" together.
Finding Chain Heads:
For each InitialHash group, records are sorted by TransferCount in descending order. The first record (highest TransferCount) is the "chain head" - the current state of that title. This is the fundamental query pattern of the blockchain model.
ForSale Filter:
Only chain heads with Status='ForSale' are included in results. Titles that have been purchased but not relisted (Status='Transferred') don't appear in the marketplace.
Response Format:
Returns {items: [...], count: N} where items contains the full DynamoDB records for display. The DecimalEncoder class converts Decimal types to floats for JSON serialization (DynamoDB uses Decimal for numbers).
Frontend Usage:
The dashboard calls this endpoint on load and displays each item as a card showing grain type, quantity, price, seller, and validation status. Users can click "Buy This Title" on any listing they don't own.
📄 grainApp-sales.py
import json import boto3 from decimal import Decimal from collections import defaultdict dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) def lambda_handler(event, context): try: response = table.scan(ConsistentRead=True) all_items = response.get('Items', []) while 'LastEvaluatedKey' in response: response = table.scan( ExclusiveStartKey=response['LastEvaluatedKey'], ConsistentRead=True ) all_items.extend(response.get('Items', [])) print(f"Total records: {len(all_items)}") chains = defaultdict(list) for item in all_items: initial_hash = item.get('InitialHash', item.get('TitleHash')) chains[initial_hash].append(item) for_sale = [] for initial_hash, records in chains.items(): records.sort(key=lambda x: int(x.get('TransferCount', 0)), reverse=True) head = records[0] if head.get('Status') == 'ForSale': for_sale.append(head) print(f"ForSale chain heads: {len(for_sale)}") return { 'statusCode': 200, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({ 'items': for_sale, 'count': len(for_sale) }, cls=DecimalEncoder) } except Exception as e: print(f"ERROR: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({'error': str(e)}) } handler = lambda_handler
get_my_titles
Returns all titles owned by the authenticated user
Detailed Operation
This function shows users what grain titles they currently own, enabling them to manage their portfolio and relist titles for sale.
User Identification:
The authenticated user's email is extracted from Cognito claims and normalized to lowercase. This will be matched against SellerID fields (remember: SellerID means "current owner").
Same Scan Pattern:
Like grainApp-sales, this function scans the full table, groups by InitialHash, and finds chain heads.
Ownership Matching:
For each chain head, the function compares SellerID (lowercase) against the user's email. A match means the user currently owns that title. The comparison is case-insensitive to handle email capitalization variations.
All Statuses Included:
Unlike the sales endpoint which filters for 'ForSale', this returns all chain heads owned by the user regardless of status.
This includes:
- 'ForSale': Titles the user is currently selling
- 'Transferred': Titles the user purchased but hasn't relisted
Response Contents:
Returns the matching chain heads with full record details. The frontend displays these with "Relist for Sale" buttons on transferred titles and "Already For Sale" indicators on listed ones.
Portfolio View:
Users can see their total holdings, track which titles they've listed vs. holding, and make decisions about when to relist based on current market conditions.
User Identification:
The authenticated user's email is extracted from Cognito claims and normalized to lowercase. This will be matched against SellerID fields (remember: SellerID means "current owner").
Same Scan Pattern:
Like grainApp-sales, this function scans the full table, groups by InitialHash, and finds chain heads.
Ownership Matching:
For each chain head, the function compares SellerID (lowercase) against the user's email. A match means the user currently owns that title. The comparison is case-insensitive to handle email capitalization variations.
All Statuses Included:
Unlike the sales endpoint which filters for 'ForSale', this returns all chain heads owned by the user regardless of status.
This includes:
- 'ForSale': Titles the user is currently selling
- 'Transferred': Titles the user purchased but hasn't relisted
Response Contents:
Returns the matching chain heads with full record details. The frontend displays these with "Relist for Sale" buttons on transferred titles and "Already For Sale" indicators on listed ones.
Portfolio View:
Users can see their total holdings, track which titles they've listed vs. holding, and make decisions about when to relist based on current market conditions.
📄 get_my_titles.py
import json import boto3 from decimal import Decimal from collections import defaultdict dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) def get_user_info(event): try: claims = event.get('requestContext', {}).get('authorizer', {}).get('claims', {}) if not claims: return None email = claims.get('email', 'unknown@example.com') username = claims.get('cognito:username', 'unknown') user_sub = claims.get('sub', '') groups_claim = claims.get('cognito:groups', '') if isinstance(groups_claim, str): groups = [g.strip() for g in groups_claim.split(',')] if groups_claim else [] else: groups = groups_claim if isinstance(groups_claim, list) else [] is_admin = 'Admin' in groups or 'Admins' in groups return { 'email': email, 'username': username, 'sub': user_sub, 'groups': groups, 'role': 'Admin' if is_admin else 'User', 'is_admin': is_admin } except Exception as e: print(f"Error extracting user info: {str(e)}") return None def lambda_handler(event, context): try: user_info = get_user_info(event) if not user_info: return { 'statusCode': 401, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({'error': 'Unauthorized'}) } user_email = user_info['email'].lower() print(f"Loading titles for user: {user_email}") response = table.scan(ConsistentRead=True) all_items = response.get('Items', []) while 'LastEvaluatedKey' in response: response = table.scan( ExclusiveStartKey=response['LastEvaluatedKey'], ConsistentRead=True ) all_items.extend(response.get('Items', [])) print(f"Total records: {len(all_items)}") chains = defaultdict(list) for item in all_items: initial_hash = item.get('InitialHash', item.get('TitleHash')) chains[initial_hash].append(item) my_titles = [] for initial_hash, records in chains.items(): records.sort(key=lambda x: int(x.get('TransferCount', 0)), reverse=True) head = records[0] seller_id = (head.get('SellerID') or '').lower() if seller_id == user_email: my_titles.append(head) print(f"User owns {len(my_titles)} chain heads") return { 'statusCode': 200, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({ 'items': my_titles, 'count': len(my_titles), 'user_email': user_email }, cls=DecimalEncoder) } except Exception as e: print(f"ERROR: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({'error': str(e)}) } handler = lambda_handler
grainApp-get-ownership-history
Returns complete ownership chain for a title
Detailed Operation
This function provides full transparency into a title's history - every creation, transfer, and relist event from genesis to current state. This Lambda function endpoint is POST rather than GET because the function expects titleId from event['body'], which only works with POST/PUT/PATCH requests.
Input:
Takes a title_hash which can be any hash in the chain (not just the current head). This allows users to click on any record and see the full history.
Finding the Chain:
First, the function fetches the clicked record to get its InitialHash. Then it scans for ALL records with that InitialHash. This retrieves the complete chain regardless of which record was clicked.
Chronological Sorting:
Records are sorted by TransferCount ascending, showing the chronological order: original creation (TransferCount=0), first transfer (1), second transfer (2), etc.
Record Formatting:
Each record is transformed by format_record() which extracts display-friendly fields:
- Owner determination: For TransferCount=0, owner is CreatedBy; for transfers, owner is SellerID
- Price and total value calculation
- Timestamp formatting for human readability
- Sold_to field showing who purchased from this owner
Current Record Marking:
The record matching the originally clicked hash is marked with is_current=True. The frontend highlights this record in the history modal.
Visual Display:
The dashboard shows this in a modal with each record as a card. The origin record has a yellow border, the current record has a green border, and intermediate transfers are gray. Each shows the hash, owner, buyer (if sold), price, status, and timestamp.
Audit Trail:
This provides a complete audit trail for any title. Inspectors, auditors, or buyers can verify the complete provenance of any grain title in the system.
Input:
Takes a title_hash which can be any hash in the chain (not just the current head). This allows users to click on any record and see the full history.
Finding the Chain:
First, the function fetches the clicked record to get its InitialHash. Then it scans for ALL records with that InitialHash. This retrieves the complete chain regardless of which record was clicked.
Chronological Sorting:
Records are sorted by TransferCount ascending, showing the chronological order: original creation (TransferCount=0), first transfer (1), second transfer (2), etc.
Record Formatting:
Each record is transformed by format_record() which extracts display-friendly fields:
- Owner determination: For TransferCount=0, owner is CreatedBy; for transfers, owner is SellerID
- Price and total value calculation
- Timestamp formatting for human readability
- Sold_to field showing who purchased from this owner
Current Record Marking:
The record matching the originally clicked hash is marked with is_current=True. The frontend highlights this record in the history modal.
Visual Display:
The dashboard shows this in a modal with each record as a card. The origin record has a yellow border, the current record has a green border, and intermediate transfers are gray. Each shows the hash, owner, buyer (if sold), price, status, and timestamp.
Audit Trail:
This provides a complete audit trail for any title. Inspectors, auditors, or buyers can verify the complete provenance of any grain title in the system.
📄 grainApp-get-ownership-history.py
import json import boto3 from decimal import Decimal from datetime import datetime dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('GrainTitles') class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) def lambda_handler(event, context): try: body = json.loads(event['body']) if isinstance(event.get('body'), str) else event.get('body', {}) title_hash = body.get('title_hash') if not title_hash: return { 'statusCode': 400, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 'body': json.dumps({'error': 'title_hash is required'}) } print(f"Looking up ownership history for TitleHash: {title_hash}") response = table.get_item(Key={'TitleHash': title_hash}) if 'Item' not in response: print(f"Title hash {title_hash} not found") return { 'statusCode': 404, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 'body': json.dumps({'error': 'Title not found', 'ownership_history': []}) } initial_hash = response['Item'].get('InitialHash') if not initial_hash: print("No InitialHash found in record") formatted_record = format_record(response['Item'], is_current=True) return { 'statusCode': 200, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 'body': json.dumps({'ownership_history': [formatted_record]}, cls=DecimalEncoder) } print(f"InitialHash: {initial_hash}") history_response = table.scan( FilterExpression='InitialHash = :initial_hash', ExpressionAttributeValues={':initial_hash': initial_hash} ) history_items = history_response.get('Items', []) while 'LastEvaluatedKey' in history_response: history_response = table.scan( FilterExpression='InitialHash = :initial_hash', ExpressionAttributeValues={':initial_hash': initial_hash}, ExclusiveStartKey=history_response['LastEvaluatedKey'] ) history_items.extend(history_response.get('Items', [])) print(f"Found {len(history_items)} records in chain") history_items.sort(key=lambda x: int(x.get('TransferCount', 0))) formatted_history = [] for idx, item in enumerate(history_items): is_current = (item.get('TitleHash') == title_hash) formatted_record = format_record(item, is_current=is_current) formatted_history.append(formatted_record) return { 'statusCode': 200, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 'body': json.dumps({ 'ownership_history': formatted_history, 'total_transfers': len(formatted_history) - 1 if formatted_history else 0 }, cls=DecimalEncoder) } except Exception as ex: print(f"Error: {str(ex)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, 'body': json.dumps({'error': str(ex), 'ownership_history': []}) } def format_record(item, is_current=False): """Format DynamoDB record for dashboard display""" price = float(item.get('PriceString') or item.get('Price') or 0) quantity = int(item.get('Quantity') or 0) total_value = price * quantity transfer_count = int(item.get('TransferCount') or 0) status = item.get('Status', '') timestamp = item.get('HashTimestamp') or item.get('CreatedAt') or item.get('TimestampISO') or '' try: if timestamp: dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) timestamp_readable = dt.strftime('%Y-%m-%d %H:%M:%S') else: timestamp_readable = 'Unknown' except: timestamp_readable = timestamp or 'Unknown' seller_id = item.get('SellerID') or 'Unknown' buyer_id = item.get('BuyerID') or 'NONE' created_by = item.get('CreatedBy') or seller_id transferred_by = item.get('TransferredBy', '') transferred_to = item.get('TransferredTo', '') if transfer_count == 0: owner_display = created_by sold_to = buyer_id if buyer_id != 'NONE' else None else: owner_display = seller_id sold_to = buyer_id if buyer_id != 'NONE' else None return { 'hash': item.get('TitleHash') or item.get('CurrentHash'), 'owner': owner_display, 'seller_id': seller_id, 'created_by': created_by, 'buyer_id': buyer_id, 'sold_to': sold_to, 'transferred_by': transferred_by, 'transferred_to': transferred_to, 'grain_type': item.get('GrainType'), 'quantity': quantity, 'price': price, 'total_value': total_value, 'timestamp_readable': timestamp_readable, 'status': status, 'transfer_count': transfer_count, 'is_current': is_current } handler = lambda_handler
grainApp-dashboard
Serves the main application dashboard HTML
Detailed Operation
This function returns the complete single-page application HTML, CSS, and JavaScript for the grain trading dashboard.
Lambda-Served SPA:
Rather than hosting static files in S3, the entire frontend is embedded in this Lambda function. This simplifies deployment - updating the dashboard is just a Lambda code update.
Authentication Check:
The JavaScript immediately checks for id_token in sessionStorage. If missing, it redirects to the login page. This client-side check is supplemented by server-side JWT validation on API calls.
User Interface Sections:
- Header: Shows user email, role, and navigation buttons
- Marketplace: Grid of ForSale titles with buy buttons
- My Titles: User's owned titles with relist buttons
- List New Sale: Form to create new grain listings
- Admin Stats: Statistics panel (visible only to admins)
API Integration:
JavaScript functions make fetch() calls to the various Lambda endpoints:
- loadSales() → GET /sales
- loadMyTitles() → GET /my-titles
- submitNewSale() → POST /titles
- submitPurchase() → POST /transfer-title
- submitRelist() → POST /relist-title
- validateTitle() → POST /validate-hash
- showHistoryModal() → POST /ownership-history
- loadAdminStats() → GET /admin-stats
All requests include the Authorization header with the id_token.
Real-Time Validation:
When listings load, the dashboard calls validateTitle() for each one and displays a colored badge (green=Valid, red=Invalid) next to each listing.
Ownership Indicators:
The UI marks listings with "Your Listing" (yellow) or "You Own This" (blue) badges by comparing SellerID/BuyerID against the logged-in user's email.
Modal System:
A modal dialog shows ownership history when users click on a hash.
Lambda-Served SPA:
Rather than hosting static files in S3, the entire frontend is embedded in this Lambda function. This simplifies deployment - updating the dashboard is just a Lambda code update.
Authentication Check:
The JavaScript immediately checks for id_token in sessionStorage. If missing, it redirects to the login page. This client-side check is supplemented by server-side JWT validation on API calls.
User Interface Sections:
- Header: Shows user email, role, and navigation buttons
- Marketplace: Grid of ForSale titles with buy buttons
- My Titles: User's owned titles with relist buttons
- List New Sale: Form to create new grain listings
- Admin Stats: Statistics panel (visible only to admins)
API Integration:
JavaScript functions make fetch() calls to the various Lambda endpoints:
- loadSales() → GET /sales
- loadMyTitles() → GET /my-titles
- submitNewSale() → POST /titles
- submitPurchase() → POST /transfer-title
- submitRelist() → POST /relist-title
- validateTitle() → POST /validate-hash
- showHistoryModal() → POST /ownership-history
- loadAdminStats() → GET /admin-stats
All requests include the Authorization header with the id_token.
Real-Time Validation:
When listings load, the dashboard calls validateTitle() for each one and displays a colored badge (green=Valid, red=Invalid) next to each listing.
Ownership Indicators:
The UI marks listings with "Your Listing" (yellow) or "You Own This" (blue) badges by comparing SellerID/BuyerID against the logged-in user's email.
Modal System:
A modal dialog shows ownership history when users click on a hash.
📄 grainApp-dashboard.py
import json def handler(event, context): return { 'statusCode': 200, 'headers': {'Content-Type': 'text/html'}, 'body': r'''<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Grain Title Tracking Dashboard</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: linear-gradient(135deg, #3a3a3a 0%, #4d4d4d 50%, #606060 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1400px; margin: 0 auto; background: white; border-radius: 15px; box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden; } .header { background: linear-gradient(135deg, #3d6ba8 0%, #2c5282 100%); color: white; padding: 30px; } .header h1 { font-size: 2.5em; margin-bottom: 10px; } .user-info { margin-top: 10px; background: rgba(255,255,255,0.15); padding: 15px; border-radius: 8px; } .nav-buttons { display: flex; gap: 10px; padding: 20px; background: #f8f9fa; flex-wrap: wrap; } .nav-btn { padding: 12px 24px; border: none; border-radius: 8px; cursor: pointer; font-size: 1em; font-weight: 600; transition: all 0.3s; } .nav-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); } .btn-primary { background: #007bff; color: white; } .btn-success { background: #28a745; color: white; } .btn-warning { background: #ffc107; color: #000; } .btn-secondary { background: #6c757d; color: white; } .btn-danger { background: #dc3545; color: white; } .btn-purple { background: #6f42c1; color: white; } .content { padding: 30px; min-height: 400px; } .loading { text-align: center; padding: 50px; font-size: 1.2em; color: #666; } .item-card { background: #f8f9fa; border: 2px solid #dee2e6; border-radius: 10px; padding: 20px; margin-bottom: 15px; transition: all 0.3s; } .item-card:hover { background: #e9ecef; border-color: #2c5282; box-shadow: 0 4px 12px rgba(44,82,130,0.3); } .item-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px; } .item-header h3 { color: #2c5282; font-size: 1.1em; margin: 0; } .item-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; } .detail-row { display: flex; flex-direction: column; } .detail-label { font-size: 0.85em; color: #666; font-weight: 600; text-transform: uppercase; margin-bottom: 5px; } .detail-value { font-size: 1.1em; font-weight: 600; color: #333; } .grain-badge { display: inline-block; padding: 5px 15px; border-radius: 15px; font-weight: 600; font-size: 0.9em; } .grain-corn { background: #fff3cd; color: #856404; } .grain-wheat { background: #f8d7da; color: #721c24; } .grain-soybean { background: #d4edda; color: #155724; } .grain-oats { background: #d1ecf1; color: #0c5460; } .validation-status { padding: 5px 12px; border-radius: 12px; font-weight: 600; font-size: 0.85em; } .status-valid { background: #d4edda; color: #155724; } .status-invalid { background: #f8d7da; color: #721c24; } .status-checking { background: #fff3cd; color: #856404; } .form-container { background: #fff; border-radius: 10px; padding: 30px; max-width: 800px; margin: 0 auto; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; font-weight: 600; color: #333; } .form-group input, .form-group select { width: 100%; padding: 12px; border: 2px solid #dee2e6; border-radius: 8px; font-size: 1em; } .form-group input:focus, .form-group select:focus { outline: none; border-color: #2c5282; } .form-actions { display: flex; gap: 10px; margin-top: 30px; } .alert { padding: 15px; border-radius: 8px; margin-bottom: 20px; } .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; } .hash-info { background: #f8f9fa; padding: 15px; border-radius: 8px; margin: 15px 0; border: 2px solid #dee2e6; } .hash-label { font-weight: 600; color: #2c5282; margin-bottom: 5px; } .hash-value { font-family: monospace; word-break: break-all; color: #495057; font-size: 0.9em; } .hash-clickable { cursor: pointer; transition: all 0.3s; } .hash-clickable:hover { background: #e9ecef; border-color: #2c5282; } .hash-hint { font-size: 0.85em; color: #666; font-style: italic; margin-top: 5px; } .price-hint { font-size: 0.85em; color: #666; margin-top: 5px; } .action-btn { padding: 10px 25px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; transition: all 0.3s; margin-right: 10px; } .action-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.2); } .action-btn:disabled { background: #6c757d; cursor: not-allowed; transform: none; } .buy-btn { background: #28a745; color: white; } .relist-btn { background: #17a2b8; color: white; } .user-indicator { background: #e7f3ff; border: 2px solid #007bff; padding: 12px 15px; border-radius: 8px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; } .user-indicator-text { font-weight: 600; color: #0056b3; } .own-listing { background: #fff3cd; border-color: #ffc107; } .own-listing-badge { background: #ffc107; color: #856404; padding: 3px 10px; border-radius: 10px; font-size: 0.8em; font-weight: 600; margin-left: 10px; } .owned-title { background: #e7f3ff; border-color: #007bff; } .owned-badge { background: #007bff; color: white; padding: 3px 10px; border-radius: 10px; font-size: 0.8em; font-weight: 600; margin-left: 10px; } .status-badge { display: inline-block; padding: 5px 15px; border-radius: 15px; font-weight: 600; font-size: 0.85em; } .status-forsale { background: #d4edda; color: #155724; } .status-transferred { background: #d1ecf1; color: #0c5460; } .empty-state { text-align: center; padding: 50px; color: #666; } .empty-state h3 { margin-bottom: 15px; color: #333; } .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); } .modal-content { background-color: white; margin: 5% auto; padding: 30px; border-radius: 15px; max-width: 900px; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; padding-bottom: 15px; border-bottom: 3px solid #2c5282; } .modal-header h2 { color: #2c5282; margin: 0; } .close-modal { font-size: 32px; font-weight: bold; color: #666; cursor: pointer; } .close-modal:hover { color: #dc3545; } .history-section { background: #f0f4f8; padding: 20px; border-radius: 10px; margin: 20px 0; border-left: 4px solid #2c5282; } .history-title { font-size: 1.2em; font-weight: 700; color: #2c5282; margin-bottom: 15px; } .history-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid #6c757d; } .history-item.origin { border-left-color: #ffc107; background: #fff9e6; } .history-item.current { border-left-color: #28a745; background: #e6f7e9; } .filter-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 15px; } .filter-bar h2 { color: #2c5282; margin: 0; } .filter-select { padding: 8px 12px; border: 2px solid #dee2e6; border-radius: 6px; font-size: 0.9em; cursor: pointer; } </style> </head> <body> <div class="container"> <div class="header"> <h1>Grain Title Tracking System</h1> <div class="user-info"> <span id="userEmail"></span> | Role: <span id="userRole"></span> </div> </div> <div class="nav-buttons"> <button class="nav-btn btn-primary" onclick="loadSales()">Marketplace</button> <button class="nav-btn btn-purple" onclick="loadMyTitles()">My Titles</button> <button class="nav-btn btn-success" onclick="showListSaleForm()">List New Sale</button> <button class="nav-btn btn-warning" onclick="loadAdminStats()" id="adminBtn" style="display:none;">Admin Stats</button> <button class="nav-btn btn-danger" onclick="logout()">Logout</button> </div> <div id="content" class="content"> <div class="loading">Loading...</div> </div> </div> <div id="historyModal" class="modal"> <div class="modal-content"> <div class="modal-header"> <h2>Complete Ownership History</h2> <span class="close-modal" onclick="closeHistoryModal()">×</span> </div> <div id="modalHistoryContent"></div> </div> </div> <script> var token = sessionStorage.getItem("id_token"); var email = sessionStorage.getItem("user_email") || ""; var role = sessionStorage.getItem("user_role") || ""; if (!token) { window.location.href = "/"; } document.getElementById("userEmail").textContent = email || "Unknown"; document.getElementById("userRole").textContent = role || "User"; if (role === "Admin") { document.getElementById("adminBtn").style.display = "block"; } var grainPrices = {"Corn": 4.85, "Wheat": 6.25, "Soybean": 13.50, "Oats": 3.75}; loadSales(); function loadAdminStats() { document.getElementById("content").innerHTML = '<div class="loading">Loading statistics...</div>'; fetch("/admin-stats", { headers: { "Authorization": token } }) .then(function(res) { if (res.status === 403) { throw new Error("Forbidden"); } return res.json(); }) .then(function(stats) { var html = '<h2 style="color:#2c5282;margin-bottom:25px;">System Statistics</h2>'; html += '<div class="item-card"><h3>System Overview</h3><div class="item-details">'; html += '<div class="detail-row"><span class="detail-label">Total Titles Issued</span><span class"detail-value">' + stats.system_stats.total_titles + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Original Titles</span><span class="detail-value">' + stats.system_stats.original_titles + '</span></div>'; html += '</div></div>'; html += '<div class="item-card"><h3>Status Breakdown</h3><div class="item-details">'; html += '<div class="detail-row"><span class="detail-label">For Sale</span><span class="detail-value">' + stats.status_breakdown.for_sale + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Transferred</span><span class="detail-value">' + stats.status_breakdown.transferred + '</span></div>'; html += '</div></div>'; html += '<div class="item-card"><h3>Grain Distribution Transfers By Type</h3><div class="item-details">'; for (var grain instats.grain_type_breakdown) { html += '<div class="detail-row"><span class="detail-label">' + grain + '</span><span class="detail-value">' + stats.grain_type_breakdown[grain] + '</span></div>'; } html += '</div></div>'; html += '<div class="item-card"><h3>Volume Statistics</h3><div class="item-details">'; html += '<div class="detail-row"><span class="detail-label">Total Bushels</span><span class="detail-value">' + stats.volume_stats.total_bushels.toLocaleString() + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Total Value</span><span class="detail-value">$' + stats.volume_stats.total_value_usd.toLocaleString() + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Avg Price/Bushel</span><span class="detail-value">$' + stats.volume_stats.average_price_per_bushel.toFixed(2) + '</span></div>'; html += '</div></div>'; document.getElementById("content").innerHTML = html; }) .catch(function(err) { document.getElementById("content").innerHTML = '<div class="alert alert-danger">Error: ' + err.message + '</div>'; }); } function showHistoryModal(titleHash, grainType) { document.getElementById("modalHistoryContent").innerHTML = '<div class="loading">Loading history...</div>'; document.getElementById("historyModal").style.display = "block"; fetch("/ownership-history", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": token }, body: JSON.stringify({ title_hash: titleHash }) }) .then(function(res) { return res.json(); }) .then(function(response) { var history = response.ownership_history || []; var html = ""; if (history.length > 0) { html = '<div class="history-section"><div class="history-title">' + history.length + ' Record(s) in Chain - ' + grainType + '</div>'; history.forEach(function(record, index) { var itemClass = record.is_current ? "current" : (index === 0 ? "origin" : ""); html += '<div class="history-item ' + itemClass + '">'; html += '<div><span class="history-label">Transfer #:</span> ' + record.transfer_count + '</div>'; html += '<div><span class="history-label">Owner:</span> ' + (record.owner || "Unknown") + '</div>'; if (record.sold_to) { html += '<div><span class="history-label">Sold To:</span> ' + record.sold_to + '</div>'; } html += '<div><span class="history-label">Price:</span> $' + (record.price || 0).toFixed(2) + '/bu</div>'; html += '<div><span class="history-label">Status:</span> ' + (record.status || "Unknown") + '</div>'; html += '<div><span class="history-label">Date:</span> ' + (record.timestamp_readable || "Unknown") + '</div>'; html += '<div class="hash-info"><div class="hash-label">Hash:</div><div class="hash-value">' + record.hash + '</div></div>'; html += '</div>'; }); html += '</div>'; } else { html = "<p>No history available.</p>"; } document.getElementById("modalHistoryContent").innerHTML = html; }) .catch(function(err) { document.getElementById("modalHistoryContent").innerHTML = "<p>Error: " + err.message + "</p>"; }); } function closeHistoryModal() { document.getElementById("historyModal").style.display = "none"; } window.onclick = function(event) { if (event.target == document.getElementById("historyModal")) { closeHistoryModal(); } }; function validateTitle(titleHash, elementId) { fetch("/validate-hash", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": token }, body: JSON.stringify({ title_hash: titleHash, validate_full_chain: true }) }) .then(function(res) { return res.json(); }) .then(function(data) { var el = document.getElementById(elementId); if (el) { if (data.chain_valid) { el.textContent = "Valid"; el.className = "validation-status status-valid"; } else { el.textContent = "Invalid"; el.className = "validation-status status-invalid"; } } }) .catch(function(err) { var el = document.getElementById(elementId); if (el) { el.textContent = "Error"; el.className = "validation-status status-invalid"; } }); } function loadSales() { document.getElementById("content").innerHTML = '<div class="loading">Loading marketplace...</div>'; fetch("/sales", { headers: { "Authorization": token } }) .then(function(res) { return res.json(); }) .then(function(response) { var data = response.items || response.Items || response; if (!Array.isArray(data)) data = []; if (data.length === 0) { document.getElementById("content").innerHTML = '<div class="empty-state"><h3>No Titles for Sale</h3><p>Check back later or list your own.</p></div>'; return; } var html = '<div class="filter-bar"><h2>Marketplace - Titles For Sale</h2>'; html += '<div><label style="margin-right:10px;font-weight:600;color:#666;">Filter by Grain:</label>'; html += '<select class="filter-select" id="grainFilter" onchange="filterByGrain()">'; html += '<option value="">All Grain Types</option><option value="Corn">Corn</option><option value="Wheat">Wheat</option><option value="Soybean">Soybean</option><option value="Oats">Oats</option>'; html += '</select></div></div>'; data.forEach(function(item, idx) { var grainType = item.GrainType || "Unknown"; var grainClass = "grain-" + grainType.toLowerCase(); var quantity = parseInt(item.Quantity) || 0; var price = parseFloat(item.Price) || 0; var totalValue = quantity * price; var sellerId = item.SellerID || item.SellerId || item.CreatedBy || ""; var buyerId = item.BuyerID || "NONE"; var status = item.Status || ""; var titleHash = item.TitleHash; var currentHash = item.CurrentHash || item.TitleHash; var isOwnListing = (sellerId.toLowerCase() === email.toLowerCase() && status === "ForSale" && buyerId === "NONE"); var youOwnThis = (buyerId.toLowerCase() === email.toLowerCase() && status === "Transferred"); var validId = "valid-" + idx; var cardClass = isOwnListing ? "own-listing" : (youOwnThis ? "owned-title" : ""); var badge = isOwnListing ? '<span class="own-listing-badge">Your Listing</span>' : (youOwnThis ? '<span class="owned-badge">You Own This</span>' : ''); html += '<div class="item-card ' + cardClass + '" data-grain="' + grainType + '">'; html += '<div class="item-header"><h3>' + sellerId + badge + '</h3>'; html += '<span class="validation-status status-checking" id="' + validId + '">Checking...</span></div>'; html += '<div class="item-details">'; var ownerId = (status === "Transferred") ? buyerId : sellerId; html += '<div class="detail-row"><span class="detail-label">Owner ID</span><span class="detail-value">' + ownerId + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Grain Type</span><span class="detail-value"><span class="grain-badge ' + grainClass + '">' + grainType + '</span></span></div>'; html += '<div class="detail-row"><span class="detail-label">Quantity</span><span class="detail-value">' + quantity.toLocaleString() + ' Bushels</span></div>'; html += '<div class="detail-row"><span class="detail-label">Price</span><span class="detail-value">$' + price.toFixed(2) + '/bu</span></div>'; html += '<div class="detail-row"><span class="detail-label">Total Value</span><span class="detail-value">$' + totalValue.toLocaleString(undefined, {minimumFractionDigits:2}) + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Transfers</span><span class="detail-value">' + (item.TransferCount || 0) + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value"><span class="status-badge status-' + status.toLowerCase() + '">' + status + '</span></span></div>'; html += '</div>'; html += '<div class="hash-info hash-clickable" onclick="showHistoryModal(\'' + titleHash + '\', \'' + grainType + '\')"><div class="hash-label">Current Hash:</div><div class="hash-value">' + currentHash + '</div><div class="hash-hint">Click to see complete ownership history</div></div>'; html += '<div style="margin-top:15px;">'; if (isOwnListing) { html += '<button class="action-btn buy-btn" disabled>Your Listing</button>'; } else if (youOwnThis) { html += '<button class="action-btn buy-btn" disabled>You Own This</button>'; } else if (status === "ForSale") { html += '<button class="action-btn buy-btn" onclick="showBuyForm(\'' + titleHash + '\', \'' + grainType + '\', ' + quantity + ', ' + price + ')">Buy This Title</button>'; } else { html += '<button class="action-btn buy-btn" disabled>Not For Sale</button>'; } html += '</div></div>'; }); document.getElementById("content").innerHTML = html; data.forEach(function(item, idx) { var currentHash = item.CurrentHash || item.TitleHash; validateTitle(currentHash, "valid-" + idx); }); }) .catch(function(err) { document.getElementById("content").innerHTML = '<div class="alert alert-danger">Error: ' + err.message + '</div>'; }); } function filterByGrain() { var filter = document.getElementById("grainFilter").value; var cards = document.querySelectorAll(".item-card"); cards.forEach(function(card) { var grain = card.getAttribute("data-grain"); if (!filter || grain === filter) { card.style.display = "block"; } else { card.style.display = "none"; } }); } function filterMyTitlesByGrain() { var filter = document.getElementById("myTitlesGrainFilter").value; var cards = document.querySelectorAll(".item-card"); cards.forEach(function(card) { var grain = card.getAttribute("data-grain"); if (!filter || grain === filter) { card.style.display = "block"; } else { card.style.display = "none"; } }); } function loadMyTitles() { document.getElementById("content").innerHTML = '<div class="loading">Loading your titles...</div>'; fetch("/my-titles", { headers: { "Authorization": token } }) .then(function(res) { return res.json(); }) .then(function(response) { var data = response.items || response.Items || response; if (!Array.isArray(data)) data = []; if (data.length === 0) { document.getElementById("content").innerHTML = '<div class="empty-state"><h3>No Titles Owned</h3><p>Visit the Marketplace to purchase grain titles.</p><button class="nav-btn btn-primary" onclick="loadSales()" style="margin-top:20px;">Go to Marketplace</button></div>'; return; } var html = '<div class="filter-bar"><h2>My Titles</h2>'; html += '<div><label style="margin-right:10px;font-weight:600;color:#666;">Filter by Grain:</label>'; html += '<select class="filter-select" id="myTitlesGrainFilter" onchange="filterMyTitlesByGrain()">'; html += '<option value="">All Grain Types</option><option value="Corn">Corn</option><option value="Wheat">Wheat</option><option value="Soybean">Soybean</option><option value="Oats">Oats</option>'; html += '</select></div></div>'; data.forEach(function(item) { var grainType = item.GrainType || "Unknown"; var grainClass = "grain-" + grainType.toLowerCase(); var quantity = parseInt(item.Quantity) || 0; var price = parseFloat(item.Price) || 0; var totalValue = quantity * price; var status = item.Status || "Unknown"; var titleHash = item.TitleHash || item.CurrentHash; var canRelist = (status === "Transferred"); html += '<div class="item-card owned-title" data-grain="' + grainType + '">'; html += '<div class="item-header"><h3>' + grainType + ' - ' + quantity.toLocaleString() + ' Bushels<span class="owned-badge">You Own This</span></h3>'; html += '<span class="status-badge status-' + status.toLowerCase() + '">' + status + '</span></div>'; html += '<div class="item-details">'; html += '<div class="detail-row"><span class="detail-label">Grain Type</span><span class="detail-value"><span class="grain-badge ' + grainClass + '">' + grainType + '</span></span></div>'; html += '<div class="detail-row"><span class="detail-label">Quantity</span><span class="detail-value">' + quantity.toLocaleString() + ' Bushels</span></div>'; html += '<div class="detail-row"><span class="detail-label">Price</span><span class="detail-value">$' + price.toFixed(2) + '/bu</span></div>'; html += '<div class="detail-row"><span class="detail-label">Total Value</span><span class="detail-value">$' + totalValue.toLocaleString(undefined, {minimumFractionDigits:2}) + '</span></div>'; html += '<div class="detail-row"><span class="detail-label">Transfers</span><span class="detail-value">' + (item.TransferCount || 0) + '</span></div>'; html += '</div>'; html += '<div class="hash-info hash-clickable" onclick="showHistoryModal(\'' + titleHash + '\', \'' + grainType + '\')"><div class="hash-label">Current Hash:</div><div class="hash-value">' + titleHash + '</div></div>'; html += '<div style="margin-top:15px;">'; if (canRelist) { html += '<button class="action-btn relist-btn" onclick="showRelistForm(\'' + titleHash + '\', \'' + grainType + '\', ' + quantity + ', ' + price + ')">Relist for Sale</button>'; } else { html += '<button class="action-btn relist-btn" disabled>Already For Sale</button>'; } html += '</div></div>'; }); document.getElementById("content").innerHTML = html; }) .catch(function(err) { document.getElementById("content").innerHTML = '<div class="alert alert-danger">Error: ' + err.message + '</div>'; }); } function showListSaleForm() { var html = '<div class="form-container"><h2 style="color:#2c5282;margin-bottom:25px;">List New Grain Title for Sale</h2>'; html += '<div class="user-indicator"><span>👤</span><span class="user-indicator-text">Listing as: ' + email + '</span></div>'; html += '<form onsubmit="submitNewSale(event)">'; html += '<div class="form-group"><label>Grain Type *</label><select id="grainType" required onchange="updatePriceFromMarket()"><option value="">Select grain type</option><option value="Corn">Corn</option><option value="Wheat">Wheat</option><option value="Soybean">Soybean</option><option value="Oats">Oats</option></select></div>'; html += '<div class="form-group"><label>Quantity (Bushels) *</label><input type="number" id="quantity" required min="1" placeholder="Enter quantity"></div>'; html += '<div class="form-group"><label>Price per Bushel ($) *</label><input type="number" id="price" required min="0" step="0.01" placeholder="Enter price"><div class="price-hint" id="priceHint"></div></div>'; html += '<div class="form-actions"><button type="submit" class="nav-btn btn-success">List for Sale</button><button type="button" class="nav-btn btn-secondary" onclick="loadSales()">Cancel</button></div>'; html += '</form></div>'; document.getElementById("content").innerHTML = html; } function updatePriceFromMarket() { var grainType = document.getElementById("grainType").value; if (grainType && grainPrices[grainType]) { document.getElementById("price").value = grainPrices[grainType].toFixed(2); document.getElementById("priceHint").textContent = "Market price: $" + grainPrices[grainType].toFixed(2) + "/bushel"; } } function submitNewSale(event) { event.preventDefault(); var data = { grain_type: document.getElementById("grainType").value, quantity: parseInt(document.getElementById("quantity").value), price: parseFloat(document.getElementById("price").value) }; fetch("/titles", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": token }, body: JSON.stringify(data) }) .then(function(res) { return res.json(); }) .then(function(result) { if (result.error) { alert("Error: " + result.error); return; } var html = '<div class="form-container"><div class="alert alert-success"><h3>Title Listed Successfully!</h3>'; html += '<p><strong>Seller:</strong> ' + (result.seller_id || email) + '</p>'; html += '<p><strong>Grain:</strong> ' + data.grain_type + '</p>'; html += '<p><strong>Quantity:</strong> ' + data.quantity.toLocaleString() + ' bushels</p>'; html += '<p><strong>Price:</strong> $' + data.price.toFixed(2) + ' per bushel</p></div>'; html += '<div class="hash-info"><div class="hash-label">Title Hash:</div><div class="hash-value">' + (result.hash || result.titleId) + '</div></div>'; html += '<div class="form-actions"><button class="nav-btn btn-primary" onclick="loadSales()">View Marketplace</button><button class="nav-btn btn-success" onclick="showListSaleForm()">List Another</button></div></div>'; document.getElementById("content").innerHTML = html; }) .catch(function(err) { alert("Error: " + err.message); }); } function showBuyForm(titleHash, grainType, quantity, price) { var totalValue = quantity * price; var html = '<div class="form-container"><h2 style="color:#2c5282;margin-bottom:25px;">Purchase Title</h2>'; html += '<div class="user-indicator"><span>🛒</span><span class="user-indicator-text">Buying as: ' + email + '</span></div>'; html += '<div class="alert alert-info"><p><strong>Purchasing:</strong> ' + grainType + ' - ' + quantity.toLocaleString() + ' Bushels</p>'; html += '<p><strong>Price:</strong> $' + price.toFixed(2) + ' per bushel</p>'; html += '<p><strong>Total Cost:</strong> $' + totalValue.toLocaleString(undefined, {minimumFractionDigits:2}) + '</p></div>'; html += '<div class="hash-info"><div class="hash-label">Title Hash:</div><div class="hash-value">' + titleHash + '</div></div>'; html += '<div class="form-actions"><button class="nav-btn btn-success" onclick="submitPurchase(\'' + titleHash + '\')">Confirm Purchase</button><button class="nav-btn btn-secondary" onclick="loadSales()">Cancel</button></div></div>'; document.getElementById("content").innerHTML = html; } function submitPurchase(titleHash) { fetch("/transfer-title", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": token }, body: JSON.stringify({ title_hash: titleHash }) }) .then(function(res) { return res.json(); }) .then(function(result) { if (result.error) { alert("Error: " + result.error); return; } var html = '<div class="form-container"><div class="alert alert-success"><h3>Title Purchased Successfully!</h3>'; html += '<p><strong>Buyer:</strong> ' + (result.buyer_id || email) + '</p>'; html += '<p><strong>Previous Owner:</strong> ' + (result.seller_id || "Unknown") + '</p>'; html += '<p><strong>Transfer #:</strong> ' + (result.transfer_count || 1) + '</p></div>'; html += '<div class="hash-info"><div class="hash-label">New Hash:</div><div class="hash-value">' + (result.new_hash || titleHash) + '</div></div>'; html += '<div class="form-actions"><button class="nav-btn btn-purple" onclick="loadMyTitles()">View My Titles</button><button class="nav-btn btn-primary" onclick="loadSales()">Back to Marketplace</button></div></div>'; document.getElementById("content").innerHTML = html; }) .catch(function(err) { alert("Error: " + err.message); }); } function showRelistForm(titleHash, grainType, quantity, currentPrice) { var defaultPrice = grainPrices[grainType] || currentPrice; var html = '<div class="form-container"><h2 style="color:#2c5282;margin-bottom:25px;">Relist Title for Sale</h2>'; html += '<div class="user-indicator"><span>💰</span><span class="user-indicator-text">Listing as: ' + email + '</span></div>'; html += '<div class="alert alert-info"><p><strong>Relisting:</strong> ' + grainType + ' - ' + quantity.toLocaleString() + ' Bushels</p></div>'; html += '<div class="hash-info"><div class="hash-label">Title Hash:</div><div class="hash-value">' + titleHash + '</div></div>'; html += '<form onsubmit="submitRelist(event, \'' + titleHash + '\')">'; html += '<div class="form-group"><label>New Price per Bushel ($) *</label><input type="number" id="newPrice" required min="0" step="0.01" value="' + defaultPrice.toFixed(2) + '"><div class="price-hint">Market price: $' + defaultPrice.toFixed(2) + '/bushel</div></div>'; html += '<div class="form-actions"><button type="submit" class="nav-btn btn-success">List for Sale</button><button type="button" class="nav-btn btn-secondary" onclick="loadMyTitles()">Cancel</button></div>'; html += '</form></div>'; document.getElementById("content").innerHTML = html; } function submitRelist(event, titleHash) { event.preventDefault(); var data = { title_hash: titleHash, new_price: parseFloat(document.getElementById("newPrice").value) }; fetch("/relist-title", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": token }, body: JSON.stringify(data) }) .then(function(res) { return res.json(); }) .then(function(result) { if (result.error) { alert("Error: " + result.error); return; } var html = '<div class="form-container"><div class="alert alert-success"><h3>Title Listed for Sale!</h3>'; html += '<p><strong>New Price:</strong> $' + data.new_price.toFixed(2) + ' per bushel</p></div>'; html += '<div class="form-actions"><button class="nav-btn btn-primary" onclick="loadSales()">View Marketplace</button><button class="nav-btn btn-purple" onclick="loadMyTitles()">View My Titles</button></div></div>'; document.getElementById("content").innerHTML = html; }) .catch(function(err) { alert("Error: " + err.message); }); } function logout() { sessionStorage.clear(); window.location.href = "/"; } </script> </body> </html>''' }
Admin Functions
GetAdminStats
Returns system-wide statistics for administrators
Detailed Operation
This function provides administrators with a bird's-eye view of the entire system through aggregated statistics.
Admin Authorization:
Before processing, the function verifies the user belongs to the 'Admin' or 'Admins' Cognito group. Non-admin users receive a 403 Forbidden response. This is enforced server-side regardless of frontend UI restrictions.
Data Collection:
The function scans the entire GrainTitles table (with pagination handling) to collect all records. This provides the raw data for statistical analysis.
Statistics Calculated:
System Stats: - total_titles: Count of all records in the database
- original_titles: Records with TransferCount=0 (genesis blocks)
- total_transfers: Sum of all TransferCount values
- unique_users: Distinct emails in CreatedBy/SellerID fields
Status Breakdown:
- for_sale: Count where Status='ForSale'
- transferred: Count where Status='Transferred'
Grain Type Distribution:
Dictionary counting records by GrainType (Corn, Wheat, Soybean, Oats)
Volume Statistics: - total_bushels: Sum of all Quantity fields
- total_value_usd: Sum of (Price × Quantity) for all records
- average_price_per_bushel: total_value / total_bushels
Decimal Handling:
DynamoDB stores numbers as Decimal types. The DecimalEncoder class converts these to floats for JSON serialization.
Audit Logging:
The function logs who requested statistics and the results, providing an audit trail of admin activity.
Dashboard Display:
The admin stats panel in the dashboard shows these metrics in organized cards: System Overview, Status Breakdown, Grain Distribution, and Volume Statistics.
Admin Authorization:
Before processing, the function verifies the user belongs to the 'Admin' or 'Admins' Cognito group. Non-admin users receive a 403 Forbidden response. This is enforced server-side regardless of frontend UI restrictions.
Data Collection:
The function scans the entire GrainTitles table (with pagination handling) to collect all records. This provides the raw data for statistical analysis.
Statistics Calculated:
System Stats: - total_titles: Count of all records in the database
- original_titles: Records with TransferCount=0 (genesis blocks)
- total_transfers: Sum of all TransferCount values
- unique_users: Distinct emails in CreatedBy/SellerID fields
Status Breakdown:
- for_sale: Count where Status='ForSale'
- transferred: Count where Status='Transferred'
Grain Type Distribution:
Dictionary counting records by GrainType (Corn, Wheat, Soybean, Oats)
Volume Statistics: - total_bushels: Sum of all Quantity fields
- total_value_usd: Sum of (Price × Quantity) for all records
- average_price_per_bushel: total_value / total_bushels
Decimal Handling:
DynamoDB stores numbers as Decimal types. The DecimalEncoder class converts these to floats for JSON serialization.
Audit Logging:
The function logs who requested statistics and the results, providing an audit trail of admin activity.
Dashboard Display:
The admin stats panel in the dashboard shows these metrics in organized cards: System Overview, Status Breakdown, Grain Distribution, and Volume Statistics.
📄 GetAdminStats.py
import json import boto3 from decimal import Decimal dynamodb = boto3.resource('dynamodb') titles_table = dynamodb.Table('GrainTitles') class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return float(o) return super().default(o) def get_user_info(event): """Extract user information from Cognito authorizer claims""" try: claims = event.get('requestContext', {}).get('authorizer', {}).get('claims', {}) if not claims: print("WARNING: No Cognito claims found in request") return None email = claims.get('email', 'unknown@example.com') username = claims.get('cognito:username', 'unknown') groups_claim = claims.get('cognito:groups', '') if isinstance(groups_claim, str): groups = [g.strip() for g in groups_claim.split(',')] if groups_claim else [] else: groups = groups_claim if isinstance(groups_claim, list) else [] is_admin = 'Admin' in groups or 'Admins' in groups role = 'Admin' if is_admin else 'User' return { 'email': email, 'username': username, 'groups': groups, 'role': role, 'is_admin': is_admin } except Exception as e: print(f"Error extracting user info: {str(e)}") return None def lambda_handler(event, context): try: user_info = get_user_info(event) if not user_info: return { 'statusCode': 401, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({'error': 'Unauthorized: Could not extract user information'}) } if not user_info['is_admin']: print(f"Access denied for non-admin user: {user_info['email']}") return { 'statusCode': 403, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({ 'error': 'Forbidden: Admin access required', 'user_role': user_info['role'] }) } print(f"Admin access granted for: {user_info['email']}") response = titles_table.scan() all_titles = response['Items'] while 'LastEvaluatedKey' in response: response = titles_table.scan(ExclusiveStartKey=response['LastEvaluatedKey']) all_titles.extend(response['Items']) total_titles = len(all_titles) for_sale = sum(1 for t in all_titles if t.get('Status') == 'ForSale') transferred = sum(1 for t in all_titles if t.get('Status') == 'Transferred') grain_counts = {} for title in all_titles: grain = title.get('GrainType', 'Unknown') grain_counts[grain] = grain_counts.get(grain, 0) + 1 total_quantity = sum(int(t.get('Quantity', 0)) for t in all_titles) total_value = sum( float(t.get('Price', 0)) * int(t.get('Quantity', 0)) for t in all_titles) total_transfers = sum(int(t.get('TransferCount', 0)) for t in all_titles) unique_users = set() for title in all_titles: if 'CreatedBy' in title: unique_users.add(title['CreatedBy']) elif 'SellerID' in title: unique_users.add(title['SellerID']) original_titles = sum(1 for t in all_titles if int(t.get('TransferCount', 0)) == 0) stats = { 'system_stats': { 'total_titles': total_titles, 'original_titles': original_titles, 'total_transfers': total_transfers, 'unique_users': len(unique_users) }, 'status_breakdown': { 'for_sale': for_sale, 'transferred': transferred }, 'grain_type_breakdown': grain_counts, 'volume_stats': { 'total_bushels': total_quantity, 'total_value_usd': round(total_value, 2), 'average_price_per_bushel': round(total_value / total_quantity, 2) if total_quantity > 0 else 0 }, 'requested_by': user_info['email'], 'timestamp': event.get('requestContext', {}).get('requestTimeEpoch', 0) } print(f"Statistics generated: {json.dumps(stats, cls=DecimalEncoder)}") return { 'statusCode': 200, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps(stats, cls=DecimalEncoder) } except Exception as e: print(f"Error generating statistics: {str(e)}") import traceback traceback.print_exc() return { 'statusCode': 500, 'headers': { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type,Authorization', 'Access-Control-Allow-Methods': 'OPTIONS,GET' }, 'body': json.dumps({'error': str(e)}) } handler = lambda_handler
Utility Functions
grainApp-backup
Backs up DynamoDB table to S3
Detailed Operation
This utility function creates point-in-time backups of the GrainTitles table by exporting all records to an S3 bucket.
Trigger:
Unlike API-triggered functions, this is designed to run on a schedule via EventBridge (CloudWatch Events). A typical schedule might be daily at midnight: cron(0 0 * * * *).
Implementation:
The function performs:
1. Scan entire GrainTitles table
2. Generate filename with current date: backup-YYYYMMDD.json
3. Convert records to JSON (using DecimalEncoder for numeric types)
4. Upload to S3 bucket specified in BUCKET environment variable
S3 Configuration: The BUCKET environment variable must be set to an S3 bucket name. The Lambda execution role needs s3:PutObject permission on this bucket.
Backup Contents: The JSON file contains an array of all DynamoDB items with their full attributes. This provides a complete snapshot that can be used for disaster recovery or data analysis.
Retention: Each backup has a unique date-based filename, so historical backups accumulate. S3 lifecycle policies can be configured to automatically delete backups older than a retention period (e.g., 30 days) or transition them to cheaper storage classes.
Recovery: To restore from backup: download the JSON file, parse it, and use DynamoDB BatchWriteItem to re-insert records.
Trigger:
Unlike API-triggered functions, this is designed to run on a schedule via EventBridge (CloudWatch Events). A typical schedule might be daily at midnight: cron(0 0 * * * *).
Implementation:
The function performs:
1. Scan entire GrainTitles table
2. Generate filename with current date: backup-YYYYMMDD.json
3. Convert records to JSON (using DecimalEncoder for numeric types)
4. Upload to S3 bucket specified in BUCKET environment variable
S3 Configuration: The BUCKET environment variable must be set to an S3 bucket name. The Lambda execution role needs s3:PutObject permission on this bucket.
Backup Contents: The JSON file contains an array of all DynamoDB items with their full attributes. This provides a complete snapshot that can be used for disaster recovery or data analysis.
Retention: Each backup has a unique date-based filename, so historical backups accumulate. S3 lifecycle policies can be configured to automatically delete backups older than a retention period (e.g., 30 days) or transition them to cheaper storage classes.
Recovery: To restore from backup: download the JSON file, parse it, and use DynamoDB BatchWriteItem to re-insert records.
📄 grainApp-backup.py
import json,boto3,os from datetime import datetime from decimal import Decimal class DE(json.JSONEncoder): def default(self,o):return float(o)if isinstance(o,Decimal)else super().default(o) def handler(e,c): i=boto3.resource('dynamodb').Table('GrainTitles').scan().get('Items',[]) f=f"backup-{datetime.now().strftime('%Y%m%d')}.json" boto3.client('s3').put_object(Bucket=os.environ['BUCKET'],Key=f,Body=json.dumps(i,cls=DE).encode()) return{'statusCode':200,'body':f'Backed up {len(i)} items'}