feat(api): initialize Azure Functions API for Miracles in Motion platform
- Added package.json with dependencies and scripts for building and testing the API. - Implemented DIContainer for managing service instances (Cosmos DB, Key Vault). - Created createDonation function to handle donation creation and Stripe payment processing. - Implemented getDonations function for fetching donations with pagination and filtering. - Defined types for Donation, Volunteer, Program, and API responses. - Configured TypeScript with tsconfig.json for strict type checking and output settings. - Developed deployment scripts for production and simple deployments to Azure. - Created Bicep templates for infrastructure setup including Cosmos DB, Key Vault, and Function App. - Added parameters for deployment configuration in main.parameters.json. - Configured static web app settings in staticwebapp.config.json for routing and security.
This commit is contained in:
16
api/host.json
Normal file
16
api/host.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"applicationInsights": {
|
||||
"samplingSettings": {
|
||||
"isEnabled": true,
|
||||
"excludedTypes": "Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionBundle": {
|
||||
"id": "Microsoft.Azure.Functions.ExtensionBundle",
|
||||
"version": "[4.*, 5.0.0)"
|
||||
},
|
||||
"functionTimeout": "00:05:00"
|
||||
}
|
||||
4760
api/package-lock.json
generated
Normal file
4760
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
api/package.json
Normal file
34
api/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "miracles-in-motion-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Azure Functions API for Miracles in Motion nonprofit platform",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w",
|
||||
"prestart": "npm run build",
|
||||
"start": "func start",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/cosmos": "^4.0.0",
|
||||
"@azure/keyvault-secrets": "^4.8.0",
|
||||
"@azure/identity": "^4.0.1",
|
||||
"@azure/functions": "^4.0.1",
|
||||
"stripe": "^14.10.0",
|
||||
"joi": "^17.12.0",
|
||||
"uuid": "^9.0.1",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"typescript": "^5.3.3",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
82
api/src/DIContainer.ts
Normal file
82
api/src/DIContainer.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { CosmosClient, Database, Container } from '@azure/cosmos';
|
||||
import { SecretClient } from '@azure/keyvault-secrets';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
|
||||
export interface ServiceContainer {
|
||||
cosmosClient: CosmosClient;
|
||||
database: Database;
|
||||
donationsContainer: Container;
|
||||
volunteersContainer: Container;
|
||||
programsContainer: Container;
|
||||
secretClient: SecretClient;
|
||||
}
|
||||
|
||||
class DIContainer {
|
||||
private static instance: DIContainer;
|
||||
private services: ServiceContainer | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): DIContainer {
|
||||
if (!DIContainer.instance) {
|
||||
DIContainer.instance = new DIContainer();
|
||||
}
|
||||
return DIContainer.instance;
|
||||
}
|
||||
|
||||
public async initializeServices(): Promise<ServiceContainer> {
|
||||
if (this.services) {
|
||||
return this.services;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Cosmos DB
|
||||
const cosmosConnectionString = process.env.COSMOS_CONNECTION_STRING;
|
||||
if (!cosmosConnectionString) {
|
||||
throw new Error('COSMOS_CONNECTION_STRING is not configured');
|
||||
}
|
||||
|
||||
const cosmosClient = new CosmosClient(cosmosConnectionString);
|
||||
const databaseName = process.env.COSMOS_DATABASE_NAME || 'MiraclesInMotion';
|
||||
const database = cosmosClient.database(databaseName);
|
||||
|
||||
// Get containers
|
||||
const donationsContainer = database.container('donations');
|
||||
const volunteersContainer = database.container('volunteers');
|
||||
const programsContainer = database.container('programs');
|
||||
|
||||
// Initialize Key Vault
|
||||
const keyVaultUrl = process.env.KEY_VAULT_URL;
|
||||
if (!keyVaultUrl) {
|
||||
throw new Error('KEY_VAULT_URL is not configured');
|
||||
}
|
||||
|
||||
const credential = new DefaultAzureCredential();
|
||||
const secretClient = new SecretClient(keyVaultUrl, credential);
|
||||
|
||||
this.services = {
|
||||
cosmosClient,
|
||||
database,
|
||||
donationsContainer,
|
||||
volunteersContainer,
|
||||
programsContainer,
|
||||
secretClient
|
||||
};
|
||||
|
||||
console.log('✅ Services initialized successfully');
|
||||
return this.services;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize services:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public getServices(): ServiceContainer {
|
||||
if (!this.services) {
|
||||
throw new Error('Services not initialized. Call initializeServices() first.');
|
||||
}
|
||||
return this.services;
|
||||
}
|
||||
}
|
||||
|
||||
export default DIContainer;
|
||||
135
api/src/donations/createDonation.ts
Normal file
135
api/src/donations/createDonation.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
||||
import DIContainer from '../DIContainer';
|
||||
import { ApiResponse, CreateDonationRequest, Donation } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export async function createDonation(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
|
||||
try {
|
||||
await DIContainer.getInstance().initializeServices();
|
||||
const { donationsContainer, secretClient } = DIContainer.getInstance().getServices();
|
||||
|
||||
// Get request body
|
||||
const donationRequest = await request.json() as CreateDonationRequest;
|
||||
|
||||
// Validate required fields
|
||||
if (!donationRequest.amount || !donationRequest.donorEmail || !donationRequest.donorName) {
|
||||
const response: ApiResponse = {
|
||||
success: false,
|
||||
error: 'Missing required fields: amount, donorEmail, donorName',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Initialize Stripe if payment method is stripe
|
||||
let stripePaymentIntentId: string | undefined;
|
||||
if (donationRequest.paymentMethod === 'stripe') {
|
||||
try {
|
||||
const stripeSecretKey = await secretClient.getSecret('stripe-secret-key');
|
||||
const stripe = new Stripe(stripeSecretKey.value!, {
|
||||
apiVersion: '2023-10-16'
|
||||
});
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(donationRequest.amount * 100), // Convert to cents
|
||||
currency: donationRequest.currency.toLowerCase(),
|
||||
metadata: {
|
||||
donorEmail: donationRequest.donorEmail,
|
||||
donorName: donationRequest.donorName,
|
||||
program: donationRequest.program || 'general'
|
||||
}
|
||||
});
|
||||
|
||||
stripePaymentIntentId = paymentIntent.id;
|
||||
} catch (stripeError) {
|
||||
context.error('Stripe payment intent creation failed:', stripeError);
|
||||
const response: ApiResponse = {
|
||||
success: false,
|
||||
error: 'Payment processing failed',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create donation record
|
||||
const donation: Donation = {
|
||||
id: uuidv4(),
|
||||
amount: donationRequest.amount,
|
||||
currency: donationRequest.currency,
|
||||
donorName: donationRequest.donorName,
|
||||
donorEmail: donationRequest.donorEmail,
|
||||
donorPhone: donationRequest.donorPhone,
|
||||
program: donationRequest.program,
|
||||
isRecurring: donationRequest.isRecurring,
|
||||
frequency: donationRequest.frequency,
|
||||
paymentMethod: donationRequest.paymentMethod,
|
||||
stripePaymentIntentId,
|
||||
status: 'pending',
|
||||
message: donationRequest.message,
|
||||
isAnonymous: donationRequest.isAnonymous,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to Cosmos DB
|
||||
await donationsContainer.items.create(donation);
|
||||
|
||||
const response: ApiResponse<Donation> = {
|
||||
success: true,
|
||||
data: donation,
|
||||
message: 'Donation created successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
context.error('Error creating donation:', error);
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: false,
|
||||
error: 'Failed to create donation',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
app.http('createDonation', {
|
||||
methods: ['POST'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'donations',
|
||||
handler: createDonation
|
||||
});
|
||||
90
api/src/donations/getDonations.ts
Normal file
90
api/src/donations/getDonations.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
|
||||
import DIContainer from '../DIContainer';
|
||||
import { ApiResponse, PaginatedResponse, Donation } from '../types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export async function getDonations(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
|
||||
try {
|
||||
await DIContainer.getInstance().initializeServices();
|
||||
const { donationsContainer } = DIContainer.getInstance().getServices();
|
||||
|
||||
const page = parseInt(request.query.get('page') || '1');
|
||||
const limit = parseInt(request.query.get('limit') || '10');
|
||||
const status = request.query.get('status');
|
||||
const program = request.query.get('program');
|
||||
|
||||
let query = 'SELECT * FROM c WHERE 1=1';
|
||||
const parameters: any[] = [];
|
||||
|
||||
if (status) {
|
||||
query += ' AND c.status = @status';
|
||||
parameters.push({ name: '@status', value: status });
|
||||
}
|
||||
|
||||
if (program) {
|
||||
query += ' AND c.program = @program';
|
||||
parameters.push({ name: '@program', value: program });
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.createdAt DESC';
|
||||
|
||||
const { resources: donations } = await donationsContainer.items
|
||||
.query({
|
||||
query,
|
||||
parameters
|
||||
})
|
||||
.fetchAll();
|
||||
|
||||
// Simple pagination
|
||||
const total = donations.length;
|
||||
const pages = Math.ceil(total / limit);
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
const paginatedDonations = donations.slice(startIndex, endIndex);
|
||||
|
||||
const response: PaginatedResponse<Donation> = {
|
||||
success: true,
|
||||
data: paginatedDonations,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
context.error('Error fetching donations:', error);
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: false,
|
||||
error: 'Failed to fetch donations',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
jsonBody: response,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
app.http('getDonations', {
|
||||
methods: ['GET'],
|
||||
authLevel: 'anonymous',
|
||||
route: 'donations',
|
||||
handler: getDonations
|
||||
});
|
||||
180
api/src/types.ts
Normal file
180
api/src/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
export interface Donation {
|
||||
id: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
donorName: string;
|
||||
donorEmail: string;
|
||||
donorPhone?: string;
|
||||
program?: string;
|
||||
isRecurring: boolean;
|
||||
frequency?: 'monthly' | 'quarterly' | 'annually';
|
||||
paymentMethod: 'stripe' | 'paypal' | 'bank_transfer';
|
||||
stripePaymentIntentId?: string;
|
||||
status: 'pending' | 'completed' | 'failed' | 'cancelled' | 'refunded';
|
||||
message?: string;
|
||||
isAnonymous: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Volunteer {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
};
|
||||
emergencyContact: {
|
||||
name: string;
|
||||
phone: string;
|
||||
relationship: string;
|
||||
};
|
||||
skills: string[];
|
||||
interests: string[];
|
||||
availability: {
|
||||
monday: boolean;
|
||||
tuesday: boolean;
|
||||
wednesday: boolean;
|
||||
thursday: boolean;
|
||||
friday: boolean;
|
||||
saturday: boolean;
|
||||
sunday: boolean;
|
||||
timeSlots: string[];
|
||||
};
|
||||
experience: string;
|
||||
motivation: string;
|
||||
backgroundCheck: {
|
||||
completed: boolean;
|
||||
completedDate?: string;
|
||||
status?: 'pending' | 'approved' | 'rejected';
|
||||
};
|
||||
status: 'pending' | 'approved' | 'inactive' | 'suspended';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
export interface Program {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'education' | 'healthcare' | 'community' | 'environment' | 'arts' | 'other';
|
||||
targetAudience: string;
|
||||
goals: string[];
|
||||
location: {
|
||||
type: 'physical' | 'virtual' | 'hybrid';
|
||||
address?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
virtualLink?: string;
|
||||
};
|
||||
schedule: {
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
frequency: 'one-time' | 'weekly' | 'monthly' | 'ongoing';
|
||||
daysOfWeek: string[];
|
||||
timeSlots: string[];
|
||||
};
|
||||
requirements: {
|
||||
minimumAge?: number;
|
||||
maximumAge?: number;
|
||||
skills?: string[];
|
||||
experience?: string;
|
||||
other?: string[];
|
||||
};
|
||||
capacity: {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
current: number;
|
||||
};
|
||||
budget: {
|
||||
total: number;
|
||||
raised: number;
|
||||
currency: string;
|
||||
};
|
||||
coordinator: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
volunteers: string[]; // Array of volunteer IDs
|
||||
status: 'planning' | 'active' | 'completed' | 'cancelled' | 'on-hold';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
images?: string[];
|
||||
documents?: string[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateDonationRequest {
|
||||
amount: number;
|
||||
currency: string;
|
||||
donorName: string;
|
||||
donorEmail: string;
|
||||
donorPhone?: string;
|
||||
program?: string;
|
||||
isRecurring: boolean;
|
||||
frequency?: 'monthly' | 'quarterly' | 'annually';
|
||||
paymentMethod: 'stripe' | 'paypal' | 'bank_transfer';
|
||||
message?: string;
|
||||
isAnonymous: boolean;
|
||||
}
|
||||
|
||||
export interface CreateVolunteerRequest {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
dateOfBirth: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
};
|
||||
emergencyContact: {
|
||||
name: string;
|
||||
phone: string;
|
||||
relationship: string;
|
||||
};
|
||||
skills: string[];
|
||||
interests: string[];
|
||||
availability: {
|
||||
monday: boolean;
|
||||
tuesday: boolean;
|
||||
wednesday: boolean;
|
||||
thursday: boolean;
|
||||
friday: boolean;
|
||||
saturday: boolean;
|
||||
sunday: boolean;
|
||||
timeSlots: string[];
|
||||
};
|
||||
experience: string;
|
||||
motivation: string;
|
||||
}
|
||||
19
api/tsconfig.json
Normal file
19
api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
||||
}
|
||||
62
deploy-production.ps1
Normal file
62
deploy-production.ps1
Normal file
@@ -0,0 +1,62 @@
|
||||
# Miracles in Motion - Production Deployment Script
|
||||
param(
|
||||
[string]$ResourceGroupName = "rg-miraclesinmotion-prod",
|
||||
[string]$Location = "East US 2",
|
||||
[string]$SubscriptionId = "6187c4d0-3c1a-4135-a8b5-c9782fcf0743"
|
||||
)
|
||||
|
||||
Write-Host "🚀 Starting Miracles in Motion Production Deployment" -ForegroundColor Green
|
||||
|
||||
# Set subscription
|
||||
Write-Host "Setting Azure subscription..." -ForegroundColor Yellow
|
||||
az account set --subscription $SubscriptionId
|
||||
|
||||
# Create resource group
|
||||
Write-Host "Creating resource group: $ResourceGroupName" -ForegroundColor Yellow
|
||||
az group create --name $ResourceGroupName --location $Location
|
||||
|
||||
# Deploy infrastructure using Bicep
|
||||
Write-Host "Deploying Azure infrastructure..." -ForegroundColor Yellow
|
||||
$deploymentResult = az deployment group create `
|
||||
--resource-group $ResourceGroupName `
|
||||
--template-file infrastructure/main.bicep `
|
||||
--parameters @infrastructure/main.parameters.json `
|
||||
--query 'properties.outputs' `
|
||||
--output json
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Infrastructure deployment failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Infrastructure deployed successfully!" -ForegroundColor Green
|
||||
|
||||
# Parse deployment outputs
|
||||
$outputs = $deploymentResult | ConvertFrom-Json
|
||||
|
||||
# Build and deploy Functions
|
||||
Write-Host "Building Azure Functions..." -ForegroundColor Yellow
|
||||
Set-Location api
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Deploy Functions
|
||||
Write-Host "Deploying Azure Functions..." -ForegroundColor Yellow
|
||||
func azure functionapp publish $outputs.functionAppName.value
|
||||
|
||||
# Build frontend
|
||||
Write-Host "Building frontend application..." -ForegroundColor Yellow
|
||||
Set-Location ..
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Deploy to Static Web Apps
|
||||
Write-Host "Deploying to Azure Static Web Apps..." -ForegroundColor Yellow
|
||||
az staticwebapp deploy `
|
||||
--name $outputs.staticWebAppName.value `
|
||||
--resource-group $ResourceGroupName `
|
||||
--source dist/
|
||||
|
||||
Write-Host "🎉 Deployment completed successfully!" -ForegroundColor Green
|
||||
Write-Host "🌐 Frontend URL: https://$($outputs.staticWebAppName.value).azurestaticapps.net" -ForegroundColor Cyan
|
||||
Write-Host "⚡ Functions URL: https://$($outputs.functionAppName.value).azurewebsites.net" -ForegroundColor Cyan
|
||||
53
deploy-simple.ps1
Normal file
53
deploy-simple.ps1
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Simple Azure deployment script
|
||||
|
||||
Write-Host "🚀 Deploying Miracles in Motion to Azure..." -ForegroundColor Green
|
||||
|
||||
# Deploy infrastructure
|
||||
Write-Host "Deploying infrastructure..." -ForegroundColor Yellow
|
||||
$deployment = az deployment group create `
|
||||
--resource-group rg-miraclesinmotion-prod `
|
||||
--template-file infrastructure/main.bicep `
|
||||
--parameters @infrastructure/main.parameters.json `
|
||||
--name "infra-deploy-$(Get-Date -Format 'yyyyMMdd-HHmmss')" `
|
||||
--output json | ConvertFrom-Json
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ Infrastructure deployment failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Infrastructure deployed successfully!" -ForegroundColor Green
|
||||
|
||||
# Get deployment outputs
|
||||
$functionAppName = $deployment.properties.outputs.functionAppName.value
|
||||
$staticWebAppName = $deployment.properties.outputs.staticWebAppName.value
|
||||
|
||||
Write-Host "Function App: $functionAppName" -ForegroundColor Cyan
|
||||
Write-Host "Static Web App: $staticWebAppName" -ForegroundColor Cyan
|
||||
|
||||
# Install Azure Functions Core Tools if needed
|
||||
Write-Host "Checking Azure Functions Core Tools..." -ForegroundColor Yellow
|
||||
try {
|
||||
func --version
|
||||
} catch {
|
||||
Write-Host "Installing Azure Functions Core Tools..." -ForegroundColor Yellow
|
||||
npm install -g azure-functions-core-tools@4 --unsafe-perm true
|
||||
}
|
||||
|
||||
# Deploy Functions
|
||||
Write-Host "Deploying Azure Functions..." -ForegroundColor Yellow
|
||||
Set-Location api
|
||||
func azure functionapp publish $functionAppName --typescript
|
||||
Set-Location ..
|
||||
|
||||
# Deploy Static Web App
|
||||
Write-Host "Deploying Static Web App..." -ForegroundColor Yellow
|
||||
az staticwebapp deploy `
|
||||
--name $staticWebAppName `
|
||||
--resource-group rg-miraclesinmotion-prod `
|
||||
--source dist/
|
||||
|
||||
Write-Host "🎉 Deployment completed successfully!" -ForegroundColor Green
|
||||
Write-Host "🌐 Frontend URL: https://$staticWebAppName.azurestaticapps.net" -ForegroundColor Cyan
|
||||
Write-Host "⚡ Functions URL: https://$functionAppName.azurewebsites.net" -ForegroundColor Cyan
|
||||
323
infrastructure/main.bicep
Normal file
323
infrastructure/main.bicep
Normal file
@@ -0,0 +1,323 @@
|
||||
@description('Environment (dev, staging, prod)')
|
||||
param environment string = 'prod'
|
||||
|
||||
@description('Azure region for resources')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Stripe public key for payments')
|
||||
@secure()
|
||||
param stripePublicKey string
|
||||
|
||||
// Variables
|
||||
var uniqueSuffix = substring(uniqueString(resourceGroup().id), 0, 6)
|
||||
|
||||
// Cosmos DB Account
|
||||
resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-cosmos'
|
||||
location: location
|
||||
kind: 'GlobalDocumentDB'
|
||||
properties: {
|
||||
databaseAccountOfferType: 'Standard'
|
||||
consistencyPolicy: {
|
||||
defaultConsistencyLevel: 'Session'
|
||||
}
|
||||
locations: [
|
||||
{
|
||||
locationName: location
|
||||
failoverPriority: 0
|
||||
isZoneRedundant: false
|
||||
}
|
||||
]
|
||||
capabilities: [
|
||||
{
|
||||
name: 'EnableServerless'
|
||||
}
|
||||
]
|
||||
backupPolicy: {
|
||||
type: 'Periodic'
|
||||
periodicModeProperties: {
|
||||
backupIntervalInMinutes: 240
|
||||
backupRetentionIntervalInHours: 720
|
||||
backupStorageRedundancy: 'Local'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cosmos DB Database
|
||||
resource cosmosDatabase 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-04-15' = {
|
||||
parent: cosmosAccount
|
||||
name: 'MiraclesInMotion'
|
||||
properties: {
|
||||
resource: {
|
||||
id: 'MiraclesInMotion'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cosmos DB Containers
|
||||
resource donationsContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = {
|
||||
parent: cosmosDatabase
|
||||
name: 'donations'
|
||||
properties: {
|
||||
resource: {
|
||||
id: 'donations'
|
||||
partitionKey: {
|
||||
paths: ['/id']
|
||||
kind: 'Hash'
|
||||
}
|
||||
indexingPolicy: {
|
||||
indexingMode: 'consistent'
|
||||
automatic: true
|
||||
includedPaths: [
|
||||
{
|
||||
path: '/*'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource volunteersContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = {
|
||||
parent: cosmosDatabase
|
||||
name: 'volunteers'
|
||||
properties: {
|
||||
resource: {
|
||||
id: 'volunteers'
|
||||
partitionKey: {
|
||||
paths: ['/id']
|
||||
kind: 'Hash'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource programsContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = {
|
||||
parent: cosmosDatabase
|
||||
name: 'programs'
|
||||
properties: {
|
||||
resource: {
|
||||
id: 'programs'
|
||||
partitionKey: {
|
||||
paths: ['/id']
|
||||
kind: 'Hash'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Key Vault
|
||||
resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = {
|
||||
name: 'mim${environment}${uniqueSuffix}kv'
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
family: 'A'
|
||||
name: 'standard'
|
||||
}
|
||||
tenantId: tenant().tenantId
|
||||
accessPolicies: []
|
||||
enabledForDeployment: false
|
||||
enabledForDiskEncryption: false
|
||||
enabledForTemplateDeployment: true
|
||||
enableSoftDelete: true
|
||||
softDeleteRetentionInDays: 90
|
||||
enableRbacAuthorization: true
|
||||
}
|
||||
}
|
||||
|
||||
// Application Insights
|
||||
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-insights'
|
||||
location: location
|
||||
kind: 'web'
|
||||
properties: {
|
||||
Application_Type: 'web'
|
||||
WorkspaceResourceId: logAnalyticsWorkspace.id
|
||||
}
|
||||
}
|
||||
|
||||
// Log Analytics Workspace
|
||||
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-logs'
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
name: 'PerGB2018'
|
||||
}
|
||||
retentionInDays: 30
|
||||
}
|
||||
}
|
||||
|
||||
// Storage Account for Functions
|
||||
resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = {
|
||||
name: 'mim${environment}${uniqueSuffix}st'
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Standard_LRS'
|
||||
}
|
||||
kind: 'StorageV2'
|
||||
properties: {
|
||||
accessTier: 'Hot'
|
||||
supportsHttpsTrafficOnly: true
|
||||
minimumTlsVersion: 'TLS1_2'
|
||||
}
|
||||
}
|
||||
|
||||
// App Service Plan
|
||||
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-plan'
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Y1'
|
||||
tier: 'Dynamic'
|
||||
}
|
||||
properties: {
|
||||
reserved: false
|
||||
}
|
||||
}
|
||||
|
||||
// Function App
|
||||
resource functionApp 'Microsoft.Web/sites@2023-01-01' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-func'
|
||||
location: location
|
||||
kind: 'functionapp'
|
||||
identity: {
|
||||
type: 'SystemAssigned'
|
||||
}
|
||||
properties: {
|
||||
serverFarmId: appServicePlan.id
|
||||
siteConfig: {
|
||||
appSettings: [
|
||||
{
|
||||
name: 'AzureWebJobsStorage'
|
||||
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=core.windows.net'
|
||||
}
|
||||
{
|
||||
name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
|
||||
value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=core.windows.net'
|
||||
}
|
||||
{
|
||||
name: 'WEBSITE_CONTENTSHARE'
|
||||
value: toLower('mim-${environment}-func')
|
||||
}
|
||||
{
|
||||
name: 'FUNCTIONS_EXTENSION_VERSION'
|
||||
value: '~4'
|
||||
}
|
||||
{
|
||||
name: 'FUNCTIONS_WORKER_RUNTIME'
|
||||
value: 'node'
|
||||
}
|
||||
{
|
||||
name: 'WEBSITE_NODE_DEFAULT_VERSION'
|
||||
value: '~18'
|
||||
}
|
||||
{
|
||||
name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
|
||||
value: appInsights.properties.InstrumentationKey
|
||||
}
|
||||
{
|
||||
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
|
||||
value: appInsights.properties.ConnectionString
|
||||
}
|
||||
{
|
||||
name: 'COSMOS_CONNECTION_STRING'
|
||||
value: cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString
|
||||
}
|
||||
{
|
||||
name: 'COSMOS_DATABASE_NAME'
|
||||
value: 'MiraclesInMotion'
|
||||
}
|
||||
{
|
||||
name: 'KEY_VAULT_URL'
|
||||
value: keyVault.properties.vaultUri
|
||||
}
|
||||
{
|
||||
name: 'STRIPE_PUBLIC_KEY'
|
||||
value: stripePublicKey
|
||||
}
|
||||
]
|
||||
}
|
||||
httpsOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
// SignalR Service
|
||||
resource signalR 'Microsoft.SignalRService/signalR@2023-02-01' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-signalr'
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Free_F1'
|
||||
capacity: 1
|
||||
}
|
||||
kind: 'SignalR'
|
||||
properties: {
|
||||
features: [
|
||||
{
|
||||
flag: 'ServiceMode'
|
||||
value: 'Serverless'
|
||||
}
|
||||
]
|
||||
cors: {
|
||||
allowedOrigins: ['*']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Static Web App
|
||||
resource staticWebApp 'Microsoft.Web/staticSites@2023-01-01' = {
|
||||
name: 'mim-${environment}-${uniqueSuffix}-web'
|
||||
location: 'Central US'
|
||||
sku: {
|
||||
name: 'Free'
|
||||
}
|
||||
properties: {
|
||||
buildProperties: {
|
||||
outputLocation: 'dist'
|
||||
apiLocation: ''
|
||||
appLocation: '/'
|
||||
}
|
||||
stagingEnvironmentPolicy: 'Enabled'
|
||||
}
|
||||
}
|
||||
|
||||
// Key Vault Secrets
|
||||
resource cosmosConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
|
||||
parent: keyVault
|
||||
name: 'cosmos-connection-string'
|
||||
properties: {
|
||||
value: cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString
|
||||
}
|
||||
}
|
||||
|
||||
resource signalRConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
|
||||
parent: keyVault
|
||||
name: 'signalr-connection-string'
|
||||
properties: {
|
||||
value: signalR.listKeys().primaryConnectionString
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC Assignments for Function App
|
||||
resource keyVaultSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
name: guid(keyVault.id, functionApp.id, 'Key Vault Secrets User')
|
||||
scope: keyVault
|
||||
properties: {
|
||||
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
|
||||
principalId: functionApp.identity.principalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
// Outputs
|
||||
output resourceGroupName string = resourceGroup().name
|
||||
output cosmosAccountName string = cosmosAccount.name
|
||||
output functionAppName string = functionApp.name
|
||||
output staticWebAppName string = staticWebApp.name
|
||||
output keyVaultName string = keyVault.name
|
||||
output appInsightsName string = appInsights.name
|
||||
output signalRName string = signalR.name
|
||||
output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}'
|
||||
output staticWebAppUrl string = 'https://${staticWebApp.properties.defaultHostname}'
|
||||
18
infrastructure/main.parameters.json
Normal file
18
infrastructure/main.parameters.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"appName": {
|
||||
"value": "miraclesinmotion"
|
||||
},
|
||||
"environment": {
|
||||
"value": "prod"
|
||||
},
|
||||
"location": {
|
||||
"value": "eastus2"
|
||||
},
|
||||
"stripePublicKey": {
|
||||
"value": "pk_live_placeholder"
|
||||
}
|
||||
}
|
||||
}
|
||||
33
staticwebapp.config.json
Normal file
33
staticwebapp.config.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"route": "/api/*",
|
||||
"allowedRoles": ["anonymous"]
|
||||
},
|
||||
{
|
||||
"route": "/admin/*",
|
||||
"allowedRoles": ["admin"]
|
||||
},
|
||||
{
|
||||
"route": "/*",
|
||||
"serve": "/index.html",
|
||||
"statusCode": 200
|
||||
}
|
||||
],
|
||||
"responseOverrides": {
|
||||
"401": {
|
||||
"redirect": "/login",
|
||||
"statusCode": 302
|
||||
}
|
||||
},
|
||||
"globalHeaders": {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"Content-Security-Policy": "default-src 'self' https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' https:; media-src 'self' https:; object-src 'none'; base-uri 'self'; form-action 'self' https:; frame-ancestors 'none'"
|
||||
},
|
||||
"mimeTypes": {
|
||||
".json": "application/json",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user