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
+ }
+ }
+}