diff --git a/.github/workflows/func-deploy.yml b/.github/workflows/func-deploy.yml new file mode 100644 index 0000000..cd8b750 --- /dev/null +++ b/.github/workflows/func-deploy.yml @@ -0,0 +1,87 @@ +name: Build and deploy .NET project to Azure Function App using OIDC + +# CONFIGURATION +# +# This workflow can be used to deploy your .NET project to a function app on any hosting plan, except for Container Apps (which uses functions-container-action). +# +# For an overview of using GitHub workflows with Azure Functions, see https://learn.microsoft.com/azure/azure-functions/functions-how-to-github-actions +# +# 1. Configure a federated identity credential to your GitHub branch on an Azure user-assigned managed identity. +# For instructions, follow the README at https://github.com/Azure/functions-action#use-oidc-recommended +# +# 2. Add the following values from the managed identity to your repo's variables: +# AZURE_CLIENT_ID +# AZURE_TENANT_ID +# AZURE_SUBSCRIPTION_ID +# For instructions on creating repo variables, see https://docs.github.com/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#defining-configuration-variables-for-multiple-workflows +# +# 3. Ensure your workflow is triggered by your desired event. By default, it is triggered when a push is made to main, and it can be manually run. +# For guidance on event triggers, see https://docs.github.com/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#using-events-to-trigger-workflows + +on: + push: + branches: [ main ] + workflow_dispatch: + +env: + AZURE_FUNCTIONAPP_NAME: ${{ vars.FUNCTION_APP_NAME }} + AZURE_FUNCTIONAPP_PROJECT_PATH: 'Bezalu.ProjectReporting.API' + DOTNET_VERSION: '10.0.x' + BUILD_ARTIFACT_NAME: 'Bezalu.ProjectReporting.API' + +jobs: + build: + runs-on: ubuntu-latest # Assumes your target function app is Linux-based + permissions: + id-token: write # Required for OIDC + contents: read # Required for actions/checkout + defaults: + run: + shell: bash + working-directory: ${{ env.AZURE_FUNCTIONAPP_PROJECT_PATH }} + steps: + - name: 'Checkout repository' + uses: actions/checkout@v6 + + - name: 'Set up .NET version: ${{ env.DOTNET_VERSION }}' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + # Perform additional steps such as running tests, if needed + + - name: 'Build and prepare .NET project for deployment' + run: dotnet publish --configuration Release --output ./output + + - name: Upload artifact for the deployment job + uses: actions/upload-artifact@v4 + with: + name: ${{ env.BUILD_ARTIFACT_NAME }} + path: ${{ env.AZURE_FUNCTIONAPP_PROJECT_PATH }}/output + include-hidden-files: true # Required for .NET projects + + deploy: + runs-on: ubuntu-latest # Assumes your target function app is Linux-based + needs: build + permissions: + id-token: write # Required for OIDC + steps: + - name: 'Download artifact from build job' + uses: actions/download-artifact@v4 + with: + name: ${{ env.BUILD_ARTIFACT_NAME }} + path: ./downloaded-artifact + + - name: 'Log in to Azure with AZ CLI' + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: 'Run the Azure Functions action' + uses: Azure/functions-action@v1 + id: deploy-to-function-app + with: + app-name: ${{ env.AZURE_FUNCTIONAPP_NAME }} + package: ./downloaded-artifact diff --git a/.github/workflows/swa-deploy.yml b/.github/workflows/swa-deploy.yml new file mode 100644 index 0000000..ecbe8b3 --- /dev/null +++ b/.github/workflows/swa-deploy.yml @@ -0,0 +1,64 @@ +name: Azure Static Web Apps CI/CD +permissions: + contents: read + pull-requests: write + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened, closed] + branches: + - main + +jobs: + build_and_deploy_job: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + name: Build and Deploy Job + steps: + - uses: actions/checkout@v6 + with: + submodules: true + lfs: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Install Workloads + run: dotnet workload restore + + - name: Restore + run: dotnet restore + + - name: .NET Publish + run: dotnet publish Bezalu.ProjectReporting.Web -c Release -o Bezalu.ProjectReporting.Web/publish + + - name: List publish output for debugging + run: ls -al ./Bezalu.ProjectReporting.Web/publish/wwwroot + + - name: Build And Deploy + id: builddeploy + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments) + action: "upload" + app_location: 'Bezalu.ProjectReporting.Web/publish/wwwroot' + skip_api_build: true + skip_app_build: true + + close_pull_request_job: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + name: Close Pull Request Job + steps: + - name: Close Pull Request + id: closepullrequest + uses: Azure/static-web-apps-deploy@v1 + with: + azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} + action: "close" diff --git a/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj b/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj index 6d72cbf..c21260a 100644 --- a/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj +++ b/Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj @@ -12,10 +12,10 @@ - - + + - + @@ -25,8 +25,4 @@ - - - - diff --git a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs b/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs index 60a93a1..e43ebac 100644 --- a/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs +++ b/Bezalu.ProjectReporting.API/Functions/ProjectCompletionReportFunction.cs @@ -217,6 +217,7 @@ private static Action TicketsSection(ProjectCompletionReportResponse { return c => { + const int maxNotes = 10; c.Column(col => { col.Item().Text("Tickets").FontSize(14).Bold(); @@ -233,10 +234,10 @@ private static Action TicketsSection(ProjectCompletionReportResponse inner.Item().Text($"Assigned: {ticket.AssignedTo}").FontSize(9); if (ticket.Notes?.Any() != true) return; inner.Item().Text("Notes:").FontSize(9); - foreach (var n in ticket.Notes.Take(10)) + foreach (var n in ticket.Notes.Take(maxNotes)) inner.Item().Text($" - {n}").FontSize(9); - if (ticket.Notes.Count > 10) - inner.Item().Text($" - ... ({ticket.Notes.Count - 10} more notes truncated)").FontSize(9); + if (ticket.Notes.Count > maxNotes) + inner.Item().Text($" - ... ({ticket.Notes.Count - maxNotes} more notes truncated)").FontSize(9); }); }); }; diff --git a/Bezalu.ProjectReporting.API/README.md b/Bezalu.ProjectReporting.API/README.md index 326e01a..434136e 100644 --- a/Bezalu.ProjectReporting.API/README.md +++ b/Bezalu.ProjectReporting.API/README.md @@ -10,6 +10,7 @@ Full solution providing interactive project completion reporting for ConnectWise - PDF generation via QuestPDF using already fetched report payload (no regeneration). ## End-to-End Flow + 1. User enters Project ID and triggers `POST /api/reports/project-completion`. 2. API aggregates ConnectWise data, builds `ProjectCompletionReportResponse`, calls Azure OpenAI for AI summary, returns JSON. 3. Front-end displays report (timeline, budget, phases, tickets, AI summary rendered as Markdown). @@ -19,17 +20,22 @@ Full solution providing interactive project completion reporting for ConnectWise ## Primary API Endpoints ### POST /api/reports/project-completion + Request body: -``` + +```json { "projectId":12345 } ``` + Response: `ProjectCompletionReportResponse` (see docs/contract.md). ### POST /api/reports/project-completion/pdf + Request body: full `ProjectCompletionReportResponse` previously returned by the first endpoint. Response: `application/pdf` file. ## Report Object Highlights + - Summary (status, dates, manager, company) - TimelineAnalysis (planned vs actual days, variance, adherence, performance %) - BudgetAnalysis (estimated vs actual hours, variance, adherence) @@ -38,55 +44,70 @@ Response: `application/pdf` file. - AiGeneratedSummary (markdown rendered client-side with Markdig) ## Operability Notes + - PDF endpoint expects complete report payload; front-end must retain JSON until download. - AI call cost avoided for PDF generation due to reuse of existing summary. - Truncation applied to notes for AI prompt and PDF to control size. ## Local Development + Prerequisites: + - .NET SDK - Azure Functions Core Tools - Valid ConnectWise + Azure OpenAI credentials in `local.settings.json` (excluded from source control). Run API: -``` + +```bash func start ``` + Run front-end: -``` + +```bash dotnet run --project Bezalu.ProjectReporting.Web ``` + Front-end will proxy to API according to Static Web Apps configuration/emulator or manual CORS settings if needed. ## Azure Deployment Overview + - Deploy Blazor WebAssembly output (Release) to Azure Static Web Apps. - Deploy Azure Functions project to same Static Web Apps resource (api folder) or separate Functions App (configure SWA `api_location`). - Set configuration (App Settings) for ConnectWise and Azure OpenAI keys; prefer Key Vault references in production. Required App Settings: + - `ConnectWise:BaseUrl`, `ConnectWise:CompanyId`, `ConnectWise:PublicKey`, `ConnectWise:PrivateKey`, `ConnectWise:ClientId` - `AzureOpenAI:Endpoint`, `AzureOpenAI:DeploymentName` (credential via `DefaultAzureCredential` in code; ensure managed identity / RBAC permissions) ## Performance & Size + - WASM project uses trimming + AOT for faster runtime once cached; consider disabling AOT in Debug for faster builds. - AI prompt size limited by truncation strategies in service. ## Error Handling --400 invalid project id or invalid report payload for PDF endpoint. --404 project not found. --500 unexpected processing errors. + +- 400 invalid project id or invalid report payload for PDF endpoint. +- 404 project not found. +- 500 unexpected processing errors. ## Security + - Function auth level currently `Function`; set keys or add front-end auth (e.g., Entra ID) before production. - Do not send sensitive data inside report payload for PDF endpoint; only project analysis data. ## Extensibility + - Add cached layer to reuse raw data for multiple exports. - Extend PDF sections (charts) by computing aggregates client-side and passing them in extended DTO. - Add Excel export by introducing another POST /api/reports/project-completion/excel endpoint using a spreadsheet library server-side. ## Documentation + See `/docs` for deeper details: + - architecture.md (layer & data flow) - contract.md (DTO shapes) - pdf.md (PDF composition logic) @@ -94,4 +115,5 @@ See `/docs` for deeper details: - frontend.md (UI behaviors) --- + This README targets the operational overview; for detailed structures consult docs directory. diff --git a/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj b/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj index 45401cb..67f8735 100644 --- a/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj +++ b/Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj @@ -19,9 +19,9 @@ - - - + + + @@ -30,6 +30,12 @@ + + + PreserveNewest + + + diff --git a/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js b/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js index d5d5f72..3bd7f76 100644 --- a/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js +++ b/Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js @@ -1,19 +1,19 @@ window.saveFile = (base64, fileName, mime) => { - try { - const binary = atob(base64); - const len = binary.length; - const bytes = new Uint8Array(len); - for (let i =0; i < len; i++) bytes[i] = binary.charCodeAt(i); - const blob = new Blob([bytes], { type: mime || 'application/octet-stream' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName || 'download'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url),5000); - } catch (e) { - console.error('saveFile error', e); - } + try { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([bytes], { type: mime || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName || 'download'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 5000); + } catch (e) { + console.error('saveFile error', e); + } }; \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js b/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js index 1f7f543..091da58 100644 --- a/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js +++ b/Bezalu.ProjectReporting.Web/wwwroot/service-worker.published.js @@ -8,7 +8,7 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event))); const cacheNamePrefix = 'offline-cache-'; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; -const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; +const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/, /\.webmanifest$/ ]; const offlineAssetsExclude = [ /^service-worker\.js$/ ]; // Replace with your base path if you are hosting on a subfolder. Ensure there is a trailing '/'. @@ -52,4 +52,4 @@ async function onFetch(event) { } return cachedResponse || fetch(event.request); -} +} \ No newline at end of file diff --git a/Bezalu.ProjectReporting.Web/wwwroot/staticwebapp.config.json b/Bezalu.ProjectReporting.Web/wwwroot/staticwebapp.config.json new file mode 100644 index 0000000..3eeff23 --- /dev/null +++ b/Bezalu.ProjectReporting.Web/wwwroot/staticwebapp.config.json @@ -0,0 +1,18 @@ +{ + "routes": [ + { + "route": "/api/*", + "allowedRoles": ["authenticated"] + }, + { + "route": "/login", + "rewrite": "/.auth/login/aad" + } + ], + "responseOverrides": { + "401": { + "redirect": "/.auth/login/aad?post_login_redirect_uri=.referrer", + "statusCode": 302 + } + } +}