Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/func-deploy.yml
Original file line number Diff line number Diff line change
@@ -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
64 changes: 64 additions & 0 deletions .github/workflows/swa-deploy.yml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 3 additions & 7 deletions Bezalu.ProjectReporting.API/Bezalu.ProjectReporting.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<OpenApiReference Include="..\..\..\..\Downloads\All.json" ClassName="ConnectwisePSA" Link="OpenAPIs\All.json" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.50.0-preview2" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.0.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageReference Include="QuestPDF" Version="2025.4.0" />
Expand All @@ -25,8 +25,4 @@
<ProjectReference Include="..\Bezalu.ProjectReporting.Shared\Bezalu.ProjectReporting.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<Folder Include="DTOs\" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ private static Action<IContainer> TicketsSection(ProjectCompletionReportResponse
{
return c =>
{
const int maxNotes = 10;
c.Column(col =>
{
col.Item().Text("Tickets").FontSize(14).Bold();
Expand All @@ -233,10 +234,10 @@ private static Action<IContainer> 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);
});
});
};
Expand Down
34 changes: 28 additions & 6 deletions Bezalu.ProjectReporting.API/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)
Expand All @@ -38,60 +44,76 @@ 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)
- deployment.md (Azure setup steps)
- frontend.md (UI behaviors)

---

This README targets the operational overview; for detailed structures consult docs directory.
12 changes: 9 additions & 3 deletions Bezalu.ProjectReporting.Web/Bezalu.ProjectReporting.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Markdig" Version="0.43.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0-rc.2.25502.107" PrivateAssets="all" />
<PackageReference Include="Markdig" Version="0.44.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components" Version="4.13.1" />
<PackageReference Include="Microsoft.FluentUI.AspNetCore.Components.Icons" Version="4.13.1" />
</ItemGroup>
Expand All @@ -30,6 +30,12 @@
<ProjectReference Include="..\Bezalu.ProjectReporting.Shared\Bezalu.ProjectReporting.Shared.csproj" />
</ItemGroup>

<ItemGroup>
<Content Update="wwwroot\staticwebapp.config.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>

<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>
Expand Down
34 changes: 17 additions & 17 deletions Bezalu.ProjectReporting.Web/wwwroot/js/fileSave.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 '/'.
Expand Down Expand Up @@ -52,4 +52,4 @@ async function onFetch(event) {
}

return cachedResponse || fetch(event.request);
}
}
Loading
Loading