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"]
|
||||
}
|
||||
Reference in New Issue
Block a user