Add portal: customer-facing white-labeled monitoring stack

New top-level portal/ project, peer to console/ and firmware/. Delivers a
.NET 10 + React 18 + TimescaleDB + Grafana stack, one container set per
customer behind Traefik. Built in 12 phases per FrontEndPrompt spec; no
changes to existing console or firmware.

Backend (src/Tau.Acuvim.Portal/):
- .NET 10 minimal API, Serilog, ASP.NET Identity (cookie auth, lockout).
- Single AppDbContext with identity / app / monitoring schemas.
- MigrateAsync + TimescaleBootstrapper (idempotent hypertable creation)
  + IdentityBootstrapper (seeded admin + branding) on startup.
- Pure CostCalculator + DB-backed RateService for tariffs (effective-dated,
  TOU periods, VAT, fixed charges, per-municipality timezone).
- BrandingService with logo upload to mounted volume.
- Time-series ingest + bucketed query services (time_bucket aggregates,
  ON CONFLICT for idempotent re-delivery).
- ConfigOverviewService with redaction-by-construction (passwords never in
  payload).
- DataProtection keys persisted to /data/keys volume for cookie survival
  across container restarts.

Frontend (frontend/):
- React 18 + TypeScript + Vite + Ant Design 5 + TanStack Query.
- BrandingProvider + ThemedRoot for live re-themed white-labelling.
- RequireAuth / RequireRole guards.
- Pages: Login, Dashboard, Dashboards (embedded Grafana), Sites (admin),
  Settings tabs (Branding / Rates / Users / Grafana / App config).

Infra:
- Dev (docker-compose.yml) and prod (docker-compose.prod.yml) compose
  files. Three services per customer; Traefik subdomain + same-origin
  /grafana path-prefix routing wired with labels.
- Grafana 11 with provisioned timescaledb datasource (uid pinned) and
  starter power-overview.json dashboard with device template variable.
- Compose project name documented as lowercase (Compose v2 requirement).

Tests (tests/Tau.Acuvim.Portal.Tests/):
- xUnit, 40 tests. Covers CostCalculator (period match, TZ, overlap,
  VAT, fixed), ConnectionStringResolver (all 4 precedence branches incl.
  Production refusal), TariffValidator, DayOfWeekFlag.
- All passing locally against .NET 10.

Docs:
- README.md (onboarding + 11 spec sections), OPERATIONS.md (per-customer
  provisioning, secret rotation, backup, troubleshooting), TESTING.md
  (manual integration scenarios, frontend test scaffolding recipe).

Production safety guards:
- Refuses to start if Authentication:DefaultAdminPassword is unchanged
  default in Production.
- Refuses to start if Database:AutoProvisionLocalTimescaleDb=true in
  Production.
- Prod Grafana ships with anonymous off and auth mode unset (three
  options documented in README Security) so iframe refuses to load
  until a deliberate prod auth choice is made.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 09:30:30 +02:00
parent 99864d0a8b
commit e17921a122
109 changed files with 11593 additions and 0 deletions

16
portal/.dockerignore Normal file
View File

@ -0,0 +1,16 @@
**/bin
**/obj
**/node_modules
**/dist
**/.vite
**/.vs
**/.idea
**/.git
**/.gitignore
**/.dockerignore
**/Dockerfile*
**/docker-compose*.yml
**/appsettings.Local.json
**/.env
**/.env.local
README.md

28
portal/.env.example Normal file
View File

@ -0,0 +1,28 @@
# Copy to .env for local docker compose use.
# DO NOT commit .env.
# ── Common ───────────────────────────────────────────────────────────────
# Compose project + container prefix. MUST be lowercase (Docker Compose v2 requirement).
# For a customer ID like ABC0001, set the project name to its lowercase form: abc0001.
# Containers then come out as abc0001_portal / abc0001_grafana / abc0001_timescale.
COMPOSE_PROJECT_NAME=portal-dev
# ── Production-only ──────────────────────────────────────────────────────
# Public hostname Traefik routes to this customer's portal.
# Required by docker-compose.prod.yml. Ignored by the dev compose.
CUSTOMER_HOST=abc0001.portal.example.com
# ── Database ─────────────────────────────────────────────────────────────
POSTGRES_DB=power_monitoring
POSTGRES_USER=power_user
POSTGRES_PASSWORD=change_me_for_local_only
# ── Portal authentication ───────────────────────────────────────────────
# In Production, DefaultAdminPassword MUST be changed or the app refuses to start.
Authentication__DefaultAdminEmail=admin@example.com
Authentication__DefaultAdminPassword=ChangeMe123!
# ── Grafana ──────────────────────────────────────────────────────────────
GRAFANA_ADMIN_PASSWORD=admin
# Path prefix Grafana is mounted at behind Traefik. Same-origin embed in the SPA.
Grafana__EmbedPathPrefix=/grafana

29
portal/.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# .NET
bin/
obj/
*.user
*.suo
.vs/
# Node / Vite
node_modules/
dist/
.vite/
# Local config (never commit secrets)
appsettings.Local.json
appsettings.*.Local.json
.env
.env.local
.env.*.local
# Uploaded branding assets (per-dev local storage)
App_Data/
# IDE
.idea/
*.swp
# OS
.DS_Store
Thumbs.db

28
portal/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM node:22-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj ./Tau.Acuvim.Portal/
RUN dotnet restore Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj
COPY src/Tau.Acuvim.Portal/ ./Tau.Acuvim.Portal/
RUN dotnet publish Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj \
-c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=frontend /app/frontend/dist ./wwwroot/
RUN mkdir -p /data/keys /data/branding && \
chown -R app:app /data
USER app
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Tau.Acuvim.Portal.dll"]

319
portal/OPERATIONS.md Normal file
View File

@ -0,0 +1,319 @@
# Tau Acuvim Portal — Operations
Per-customer deployment loop. For background, architecture, and security model, read the [README](./README.md) first.
---
## Contents
1. [Prerequisites (per host)](#prerequisites-per-host)
2. [Provisioning a new customer](#provisioning-a-new-customer)
3. [Updating a customer's stack](#updating-a-customers-stack)
4. [Rotating secrets](#rotating-secrets)
5. [Backup & restore](#backup--restore)
6. [Health & monitoring](#health--monitoring)
7. [Troubleshooting](#troubleshooting)
8. [Decommissioning a customer](#decommissioning-a-customer)
---
## Prerequisites (per host)
These exist once on the host running customer stacks; not per customer.
1. **Docker Engine** (or Docker Desktop on Windows hosts).
2. **External Traefik instance** — running on the same host, joined to a Docker network named `traefik-public`. Configured with:
- Two entrypoints: `web` (80), `websecure` (443).
- A certificate resolver named `le` (Let's Encrypt via DNS-01 or HTTP-01).
- HTTP → HTTPS redirect.
- Docker provider with `exposedByDefault: false`.
3. **Wildcard DNS + TLS cert** for `*.portal.example.com` (or whatever your customer subdomain pattern is).
4. **The `traefik-public` Docker network exists**:
```
docker network create traefik-public # one-time
```
5. **The portal image is built** (or pull-able from a registry):
```
cd /path/to/portal
docker compose -f docker-compose.prod.yml build
```
---
## Provisioning a new customer
Goal: spin up an isolated stack for customer `ABC0001` (Compose project `abc0001` — lowercase required) at `abc0001.portal.example.com`.
### 1. Create the customer directory
A common pattern: one directory per customer holding only an `.env` file (the compose files are shared from the repo). Adjust to your fleet-management tool of choice (Ansible, Portainer, Helm-on-K8s later).
```
/srv/portal/abc0001/
└── .env
```
### 2. Generate strong secrets
```bash
openssl rand -base64 32 # POSTGRES_PASSWORD
openssl rand -base64 32 # GRAFANA_ADMIN_PASSWORD
openssl rand -base64 32 # Authentication__DefaultAdminPassword
```
### 3. Fill in `.env`
```ini
COMPOSE_PROJECT_NAME=abc0001
CUSTOMER_HOST=abc0001.portal.example.com
POSTGRES_DB=power_monitoring
POSTGRES_USER=power_user
POSTGRES_PASSWORD=<from step 2>
Authentication__DefaultAdminEmail=admin@abc0001.example.com
Authentication__DefaultAdminPassword=<from step 2>
GRAFANA_ADMIN_PASSWORD=<from step 2>
Grafana__EmbedPathPrefix=/grafana
```
### 4. Decide Grafana auth mode
Anonymous is **off** in the prod compose by default. Pick one of the three options from the README's Security notes and wire it before exposing the stack to anyone:
- (a) Traefik `forwardAuth` → add the middleware to the `traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana` labels and implement `/api/auth/check` on the portal.
- (b) Grafana `auth.proxy` → set `GF_AUTH_PROXY_ENABLED=true`, `GF_AUTH_PROXY_HEADER_NAME=X-WEBAUTH-USER` env vars; ensure Traefik (or the portal) sets the header and that no client can.
- (c) Render tokens → minted by a portal endpoint; SPA appends `?auth_token=...` to the iframe URL.
Without any of these, Grafana refuses anonymous access in prod (intended safe default — iframe will show a login page).
### 5. Bring it up
```bash
cd /srv/portal/abc0001
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml up -d
```
### 6. Verify
```bash
# Wait for healthy
docker ps --filter "label=com.docker.compose.project=abc0001"
# Health checks
curl -fs https://abc0001.portal.example.com/health # → Healthy
curl -fs https://abc0001.portal.example.com/health/ready # → Healthy
# Migration + seed in the logs
docker logs abc0001_portal | grep -E "Applied migration|Seeded|hypertable"
# expect:
# Applied migration 'InitialCreate'
# TimescaleDB hypertable for monitoring.PowerMeasurements is ready
# Seeded default admin admin@abc0001.example.com
```
### 7. First login + handover
1. Sign in as `Authentication__DefaultAdminEmail` with the password from step 2.
2. **Settings → Users** → create the customer's real admin account; toggle Admin on.
3. Sign out, sign in as the customer admin, **change the default admin password** (or delete the default admin account if the customer admin is the only one needed).
4. **Settings → Branding** → upload customer logo, apply colours.
5. **Settings → Rates** → seed at least one municipality + tariff for cost calc.
6. **Sites** → create the customer's sites/devices so the ingest pipeline knows where measurements belong.
---
## Updating a customer's stack
### Code-only update (no migrations, no compose changes)
```bash
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \
up -d --build portal
```
Brief downtime while the new container starts. The DB is untouched.
### Update with new migrations
Same command — `MigrateAsync` on startup applies pending migrations before the app accepts traffic. Watch the logs:
```bash
docker logs -f abc0001_portal | grep -E "Applied migration|Failed|hypertable"
```
If a migration fails the container will exit; fix forward, push a corrected image, retry.
### Compose changes (env vars, ports, labels)
Edit the customer's `.env` (or the central `docker-compose.prod.yml`) and:
```bash
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml up -d
```
Compose recreates only the containers whose definition changed.
### Rolling many customers
There's no built-in fan-out — pick your orchestrator (Ansible playbook, simple bash loop, Portainer stacks). Update one customer first, verify, then roll the rest.
---
## Rotating secrets
### Database password
```bash
# 1. Change the password inside Postgres
docker exec -it abc0001_timescale psql -U power_user -d power_monitoring \
-c "ALTER USER power_user WITH PASSWORD '<new>';"
# 2. Update .env
sed -i 's/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=<new>/' .env
# 3. Recreate the portal + grafana to pick up new env vars
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \
up -d portal grafana
```
### Grafana admin password
```bash
sed -i 's/^GRAFANA_ADMIN_PASSWORD=.*/GRAFANA_ADMIN_PASSWORD=<new>/' .env
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml \
up -d grafana
```
`GF_SECURITY_ADMIN_PASSWORD` is re-applied on container start.
### Default admin password
Once the customer admin exists and has changed their own password, the default admin can be deleted from the **Settings → Users** UI. After that, `Authentication__DefaultAdminPassword` is only used if the row is re-seeded (which happens only when no account with that email exists).
---
## Backup & restore
### What to back up
| Volume | What's in it | Frequency |
|---|---|---|
| `<PREFIX>_timescale-data` | All customer data (Identity, branding, tariffs, sites, devices, measurements) | Daily, more for high-write customers |
| `<PREFIX>_grafana-data` | Grafana's internal SQLite (user prefs, plugin state). Dashboards re-provision from JSON so this is **not authoritative**. | Weekly is plenty |
| `<PREFIX>_portal-branding` | Uploaded logos | Daily |
| `<PREFIX>_portal-keys` | Data Protection key ring (cookie signing). Losing this invalidates all sessions but doesn't lose data. | Weekly |
### Postgres dump
```bash
docker exec abc0001_timescale \
pg_dump -U power_user -d power_monitoring -F c -f /tmp/backup.dump
docker cp abc0001_timescale:/tmp/backup.dump ./abc0001-$(date +%Y%m%d).dump
docker exec abc0001_timescale rm /tmp/backup.dump
```
For consistent hypertable backups, prefer Timescale's `pg_dump` (supports hypertables natively as of PG12+; the above works).
### Volume snapshot
For non-DB volumes, simplest is a `tar` from the volume's mountpoint, or use your storage layer's snapshot facility (LVM, ZFS, EBS, etc.).
### Restore
```bash
# Fresh DB
docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env down timescaledb
docker volume rm abc0001_timescale-data
docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env up -d timescaledb
# Restore
docker cp abc0001-YYYYMMDD.dump abc0001_timescale:/tmp/backup.dump
docker exec abc0001_timescale \
pg_restore -U power_user -d power_monitoring --clean --if-exists /tmp/backup.dump
docker exec abc0001_timescale rm /tmp/backup.dump
# Start everything
docker compose -f /path/to/portal/docker-compose.prod.yml --env-file .env up -d
```
The TimescaleBootstrapper is idempotent — it will not error on a restored hypertable.
---
## Health & monitoring
### Liveness / readiness
- `GET /health` — liveness. Use as Traefik / load-balancer health check.
- `GET /health/ready` — readiness (DB reachable). Use for orchestration "in service" decisions.
### Logs
Serilog writes JSON to stdout; the Docker logging driver of your choice (json-file, journald, gelf to a central log store) picks it up.
```bash
docker logs abc0001_portal --tail 200 --follow
```
Notable lines:
- `Database connection resolved via …` — confirms how this container resolved its DB at startup.
- `Applied migration '…'` — one per pending migration.
- `TimescaleDB hypertable for monitoring.PowerMeasurements is ready` — bootstrapper succeeded.
- `Seeded default admin …` — first start only; absence on subsequent starts is correct.
### DB health from the host
```bash
docker exec abc0001_timescale pg_isready -U power_user -d power_monitoring
```
### TimescaleDB chunks
```bash
docker exec -it abc0001_timescale psql -U power_user -d power_monitoring -c \
"SELECT chunk_name, range_start, range_end, total_bytes
FROM chunks_detailed_size('monitoring.\"PowerMeasurements\"');"
```
---
## Troubleshooting
| Symptom | First check |
|---|---|
| Portal container restart-looping | `docker logs <PREFIX>_portal` — usually a missing env var (default-admin password in prod, missing Postgres password) or a migration failure. |
| `/health/ready` returns Unhealthy | Postgres container down, or wrong creds. `docker logs <PREFIX>_timescale`. |
| Grafana iframe loads but no charts | Datasource UID mismatch — confirm `grafana/provisioning/datasources/timescaledb.yml` has `uid: timescaledb` and the dashboard JSON references the same. |
| Grafana iframe shows login screen in prod | Expected if no auth mode is wired yet (anonymous off by default). Pick a mode (see README → Security). |
| Branded logo missing after restart | `<PREFIX>_portal-branding` volume not mounted, or filesystem perms wrong. The container runs as user `app`; volume must be writable by uid 1000. |
| Ingest returns `accepted: 0, rejected: N` | Devices don't exist for those `externalId`s. Create them via the Sites screen first. |
| Cookie auth seems random / sessions lost on restart | `<PREFIX>_portal-keys` volume not mounted — Data Protection re-keys on every start. |
| Hypertable error on startup | Pre-existing non-empty plain table being converted. `migrate_data => TRUE` should handle it; if not, restore from backup and check for manual `monitoring."PowerMeasurements"` schema changes. |
---
## Decommissioning a customer
```bash
# Final backup
docker exec abc0001_timescale pg_dump -U power_user -d power_monitoring \
-F c -f /tmp/final.dump
docker cp abc0001_timescale:/tmp/final.dump ./abc0001-final-$(date +%Y%m%d).dump
# Stop and remove containers
docker compose --env-file .env -f /path/to/portal/docker-compose.prod.yml down
# Remove volumes (destroys data — confirm backup first)
docker volume rm \
abc0001_timescale-data \
abc0001_grafana-data \
abc0001_portal-branding \
abc0001_portal-keys
# Remove customer dir
rm -rf /srv/portal/abc0001
# DNS record + cert (manual or via your DNS automation)
```

369
portal/README.md Normal file
View File

@ -0,0 +1,369 @@
# Tau Acuvim Portal
Customer-facing, white-labeled power monitoring portal. One stack per customer, deployed behind Traefik with the customer ID (lowercased — Docker Compose v2 requirement) as the container prefix: customer `ABC0001` produces `abc0001_portal`, `abc0001_grafana`, `abc0001_timescale`.
This project lives next to `console/` (internal management interface) and `firmware/` (ESP32) in the same repo. The three projects share no code; the portal stands alone.
---
## Contents
1. [Overview](#overview)
2. [Architecture](#architecture)
3. [Configuration template](#configuration-template)
4. [Local setup](#local-setup)
5. [Docker Compose](#docker-compose)
6. [Database migrations](#database-migrations)
7. [Accessing the app](#accessing-the-app)
8. [Accessing Grafana](#accessing-grafana)
9. [Default local test credentials](#default-local-test-credentials)
10. [Production deployment notes](#production-deployment-notes)
11. [Security notes](#security-notes)
12. [Testing](#testing)
13. [Operations](#operations)
---
## Overview
A customer signs in to their own branded portal, sees their meters' live + historical power readings via embedded Grafana dashboards, and (for admins) configures branding, municipality tariffs, users, sites, and devices. Each customer gets a fully isolated stack: their own database, their own Grafana, their own branding. Traefik routes `<customer>.portal.example.com` to the right containers.
### Tech stack
| Layer | Technology |
|---|---|
| Backend | .NET 10 minimal API, EF Core 10, Npgsql, ASP.NET Core Identity, Serilog |
| Frontend | React 18 + TypeScript + Vite, Ant Design 5, TanStack Query, react-router |
| Database | TimescaleDB 2.17 on PostgreSQL 16 |
| Graphing | Grafana 11 (provisioned datasource + dashboards) |
| Container | Docker / Docker Compose; Traefik for routing |
| Auth | Cookie-based via ASP.NET Identity (SPA-friendly, 401/403 not redirects) |
---
## Architecture
### Containers, per customer
```
┌────────────────────────────────────────────────────┐
│ Traefik │
│ host: <customer>.portal.example.com │
└────────────────────────────────────────────────────┘
┌─────────────────────────┬─────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
<PREFIX>_portal │ │ <PREFIX>_grafana │ │<PREFIX>_timescale │
│ .NET API + SPA │──▶│ Grafana 11 │──▶│ TimescaleDB + Pg16│
│ :8080 │ │ :3000 │ │ :5432 │
└───────────────────┘ └───────────────────┘ └───────────────────┘
```
`<PREFIX>` = the customer's 7-digit ID **lowercased** (e.g. `abc0001` for customer `ABC0001`), set via `COMPOSE_PROJECT_NAME`. Compose v2 rejects uppercase project names.
### Backend layout
- **Combined container** — Dockerfile builds the React SPA, then a multi-stage .NET build, then copies the SPA into `wwwroot`. One image, one process per customer.
- **Single `AppDbContext`** with three schemas:
- `identity` — ASP.NET Identity tables.
- `app` — branding, municipalities, tariffs, periods.
- `monitoring` — sites, devices, power measurements (hypertable).
- **Minimal API** with endpoints grouped under `Endpoints/*.cs`. Services in `Services/*.cs`. Typed options in `Configuration/`.
### Frontend layout
- `src/pages/` — page-level components (`DashboardsPage`, `SettingsPage`, etc.).
- `src/components/` — shared + feature components.
- `src/api/` — typed axios calls.
- `src/hooks/useAuth`, `useBranding` — global state via Context.
- `RequireAuth` / `RequireRole` — route guards.
- AntD `ConfigProvider` is themed dynamically from `BrandingProvider` (white-labelling).
---
## Configuration template
All configurable values are declared in `src/Tau.Acuvim.Portal/appsettings.template.json` — checked in, no secrets. It's the lowest-priority configuration source: everything overrides it.
### Precedence (lowest → highest)
1. `appsettings.template.json` — shippable defaults (always loaded).
2. `appsettings.json` — runtime infra (Serilog, AllowedHosts).
3. `appsettings.{Environment}.json` — per-environment overrides.
4. `appsettings.Local.json` — gitignored, your local overrides.
5. Environment variables — use `__` as section separator (e.g. `Authentication__DefaultAdminPassword=...`). Production secrets go here.
### Sections
| Section | Purpose |
|---|---|
| `Application` | Name, environment, public URL |
| `Database` | Provider, connection string, `MigrateOnStartup`, `AutoProvisionLocalTimescaleDb` |
| `TimescaleDb` | Host/port/db/user/password for auto-provision in dev |
| `Grafana` | Base URL, internal URL, path prefix, embed mode, dashboard list |
| `WhiteLabel` | App name, logo URL, colours, footer, logo storage path |
| `Authentication` | Cookie name, lockout, default admin email/password |
| `Monitoring` | Hypertable chunk interval, aggregate flag |
### Database connection resolution
1. If `Database:ConnectionString` is non-empty → use it.
2. Else if `Database:AutoProvisionLocalTimescaleDb=true` AND env is not `Production` → build from the `TimescaleDb:*` block (Host/Port/Database/Username/Password).
3. Otherwise → app refuses to start with a clear error.
`AutoProvisionLocalTimescaleDb=true` in Production is a hard failure — production must supply its own connection string via env var or secret.
---
## Local setup
### Prerequisites
- .NET 10 SDK
- Node 22+ / npm
- Docker Desktop
- `dotnet-ef` tool: `dotnet tool install --global dotnet-ef`
### First-time: generate the initial migration
Identity / branding / rates / monitoring entities are defined in code; the migration files themselves are generated artifacts. Run once:
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal
dotnet ef migrations add InitialCreate `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations
```
Commit the resulting `Migrations/` folder. From then on, `MigrateAsync` on startup applies whatever exists — no manual step at deploy time.
### Option A: full stack in Docker (recommended)
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal
Copy-Item .env.example .env
docker compose up --build -d
docker compose ps
```
Then:
- Portal: http://localhost:8080
- Grafana: http://localhost:3001 (anonymous Viewer in dev)
- TimescaleDB: `localhost:5433` (user/db from `.env`)
Stop + wipe:
```powershell
docker compose down -v
```
### Option B: backend + DB in Docker, frontend via Vite
Same `docker compose up`, then in another terminal:
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal\frontend
npm install
npm run dev
```
Vite serves at http://localhost:5174 and proxies `/api` + `/health` to the .NET container on `:8080`.
### Option C: everything local (no Docker for the app)
Postgres still needs Docker (or a local install):
```powershell
docker compose up -d timescaledb
cd C:\AcuvimDev\Tau.Acuvim\portal\src\Tau.Acuvim.Portal
dotnet run # listens on :8080
# in another terminal
cd C:\AcuvimDev\Tau.Acuvim\portal\frontend
npm run dev # :5174
```
---
## Docker Compose
### Dev — `docker-compose.yml`
Three services: `portal`, `timescaledb`, `grafana`. Persistent named volumes (`timescale-data`, `grafana-data`, `portal-keys`, `portal-branding`). Healthcheck on Postgres; portal waits for healthy. Grafana ships with anonymous Viewer for easy local access; provisioned `TimescaleDB` datasource (uid: `timescaledb`) and any JSON dashboards under `grafana/dashboards/`.
Host port mappings: `8080→portal`, `5433→timescaledb`, `3001→grafana`. Chosen to coexist with the console stack.
### Prod — `docker-compose.prod.yml`
Same services, no host port mappings. Joins external `traefik-public` network. Per-customer Traefik labels (subdomain routing for portal, same-origin path-prefix routing for Grafana at `/grafana`). Grafana sub-path + `GF_SERVER_ROOT_URL` configured. All secrets via env vars.
Run:
```powershell
docker network create traefik-public # once on the host
docker compose -f docker-compose.prod.yml --env-file .env up -d
```
See [OPERATIONS.md](./OPERATIONS.md) for the full per-customer deployment loop.
---
## Database migrations
`MigrateAsync` runs on startup (controlled by `Database:MigrateOnStartup`, default `true`). Immediately after, `TimescaleBootstrapper` runs an idempotent block:
1. `CREATE EXTENSION IF NOT EXISTS timescaledb` (defensive).
2. `SELECT create_hypertable('monitoring."PowerMeasurements"', 'Time', if_not_exists => TRUE, migrate_data => TRUE)`.
3. `SELECT set_chunk_time_interval('monitoring."PowerMeasurements"', INTERVAL '<MonitoringOptions.ChunkTimeInterval>')`.
Safe to re-run on every start.
### Adding a new migration
When you change the entity model:
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal
dotnet ef migrations add <DescriptiveName> `
--project src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj `
--output-dir Migrations
```
Commit. Next deploy applies it automatically.
---
## Accessing the app
| Environment | URL |
|---|---|
| Local (Docker combined) | http://localhost:8080 |
| Local (Vite dev) | http://localhost:5174 |
| Production | `https://<customer-host>` (e.g. `https://abc0001.portal.example.com`) |
API base path: `/api`. Swagger UI in dev: `/swagger`.
### Health endpoints
- `GET /health` — liveness (process alive). Returns `Healthy` if the app responds.
- `GET /health/ready` — readiness. Returns `Healthy` only if TimescaleDB answers.
### Nav surface
| Page | Who sees it |
|---|---|
| Dashboard | Any authenticated user |
| Dashboards (embedded Grafana) | Any authenticated user |
| Sites | Admin only |
| Settings (Branding / Rates / Users / Grafana / App config) | Admin only |
---
## Accessing Grafana
### Local dev
- Direct: http://localhost:3001 — anonymous Viewer can browse provisioned dashboards. Admin/`GRAFANA_ADMIN_PASSWORD` for editing.
- Embedded: portal **Dashboards** page — iframe `src` points at the local Grafana base URL.
### Production
- Direct browser access to `<customer-host>/grafana/*` is gated by your chosen auth mode (see Security notes). Anonymous is **off** in the prod compose.
- Embedded: portal **Dashboards** page — same-origin iframe via Traefik path prefix `/grafana`.
### Provisioning
- Datasource: `grafana/provisioning/datasources/timescaledb.yml` (uid: `timescaledb`).
- Dashboard provider: `grafana/provisioning/dashboards/dashboards.yml` (auto-discovers JSON in `grafana/dashboards/`, refresh 30s).
- Starter dashboard: `grafana/dashboards/power-overview.json` — active power + cumulative energy + latest-power stat, parameterised by a `device` template variable.
To add a dashboard:
1. Drop the JSON into `grafana/dashboards/`.
2. Add an entry to `Grafana.Dashboards` in `appsettings.template.json` (or override in `appsettings.Local.json`) with the same `Uid`. The portal's Dashboards page picks it up after a refresh.
---
## Default local test credentials
Generated locally — change before publishing the stack to anyone.
- Email: `admin@example.com`
- Password: `ChangeMe123!`
Defined in `appsettings.template.json``Authentication`. The bootstrapper seeds this account only if no account with that email exists, and never overwrites a changed password.
**Production guard:** if `ASPNETCORE_ENVIRONMENT=Production` and the default password is still `ChangeMe123!`, the app refuses to start with an explicit error. Override `Authentication__DefaultAdminPassword` via env var before deploying.
---
## Production deployment notes
For the per-customer deployment loop see [OPERATIONS.md](./OPERATIONS.md). The short version:
1. **One Compose project per customer.** Set `COMPOSE_PROJECT_NAME=abc0001` (lowercase form of the customer ID — Compose v2 rejects uppercase). Containers are named `abc0001_portal`, `abc0001_grafana`, `abc0001_timescale`.
2. **One subdomain per customer.** Set `CUSTOMER_HOST=abc0001.portal.example.com`. Wildcard DNS + wildcard TLS cert via Traefik's resolver (`certresolver=le`).
3. **Decide your Grafana auth mode** (see Security notes). The prod compose deliberately leaves Grafana auth **off** so the iframe refuses to load until you pick.
4. **Set all secrets via env vars** (not files):
- `POSTGRES_PASSWORD`
- `GRAFANA_ADMIN_PASSWORD`
- `Authentication__DefaultAdminPassword`
5. **External `traefik-public` Docker network must exist** (created once on the host running Traefik).
6. **Up the stack:**
```
docker compose -f docker-compose.prod.yml --env-file .env up -d
```
7. **Verify** the three containers report healthy and `https://<customer-host>/health/ready` returns `Healthy`.
---
## Security notes
### What's protected by default
- **ASP.NET Core Identity** with lockout (5 failed attempts → 15 min) and strong password requirements (8+ chars, upper + lower + digit).
- **Cookies are HttpOnly + SameSite=Lax + Secure in prod**, scoped to the portal subdomain.
- **Admin-only endpoints** are gated by an `AdminOnly` policy (`RequireRole("Admin")`). Confirmed at backend; nav hidden on frontend.
- **Cannot delete your own account** — backend block, not just UI.
- **`GET /api/admin/config-overview`** is admin-only; the DTO never includes the connection string or any password. Redaction by construction, not filtering.
- **Branding logo upload** rejects files >2 MB and extensions outside `{png, jpg, jpeg, svg, webp}`.
- **Anti-forgery is left on by default** on cookie-authenticated endpoints; the logo upload explicitly opts out (multipart needs it disabled). Other admin endpoints accept JSON over `same-site Lax` cookies, which is CSRF-safe for state-changing same-origin SPA requests.
- **Security headers** (`X-Content-Type-Options`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: strict-origin-when-cross-origin`) on every response. HSTS in prod.
### Production refuse-to-start guards
- App refuses to start in `Production` if `Authentication:DefaultAdminPassword` is still `ChangeMe123!`.
- App refuses to start in `Production` if `Database:AutoProvisionLocalTimescaleDb=true` (you must supply an explicit connection string).
- App refuses to start if no connection string can be resolved at all.
### Grafana embedding — three production auth options
The dev compose runs Grafana with anonymous Viewer (safe on `localhost`). The prod compose has anonymous **off** and leaves the auth mode unset on purpose — pick one before publishing:
| Option | What it does | Trade-off |
|---|---|---|
| **(a) Traefik `forwardAuth`** | Traefik middleware calls a portal `/api/auth/check` endpoint on every Grafana request; portal cookie required, else 401 | Zero changes to Grafana. Best when "any portal user = same dashboards." |
| **(b) Grafana `auth.proxy`** | `GF_AUTH_PROXY_ENABLED=true`; trust an `X-WEBAUTH-USER` header set by Traefik | Maps portal user → Grafana user, gets per-user folders/perms. Sanitise the header — never let a client set it directly. |
| **(c) Service-account API key + render tokens** | Portal mints short-lived render tokens; SPA embeds via `?auth_token=...` | Most moving parts. Right when dashboards are stitched into custom UI per-panel rather than full Grafana. |
Until one is wired, prod-mode Grafana refuses anonymous access and the iframe shows a login page — the intended safe default.
### Other considerations
- Same-origin embed (prod path-prefix routing through Traefik) sidesteps third-party-cookie blockers that increasingly break cross-origin Grafana iframes.
- Provisioned datasource is `editable: false` — admins cannot accidentally rewire Grafana from its UI.
- Default password complexity is tunable in `Program.cs``IdentityOptions`. Lockout is tunable in the same block.
- **TimescaleDB licensing** — we use Apache-licensed `timescale/timescaledb:*-pg16`. Stay on community features (hypertables, continuous aggregates) if you ever sell this as managed DBaaS.
---
## Testing
Backend unit tests under `tests/Tau.Acuvim.Portal.Tests/` cover cost calculation, rate validation, connection-string resolution, day-of-week math:
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests
dotnet test
```
See [TESTING.md](./TESTING.md) for the full manual integration scenario, frontend test scaffolding recipe, and edge-case checklist.
---
## Operations
For per-customer provisioning, secret rotation, backups, and health monitoring see [OPERATIONS.md](./OPERATIONS.md).

129
portal/TESTING.md Normal file
View File

@ -0,0 +1,129 @@
# Tau Acuvim Portal — Testing
This file covers what's checked-in (backend unit tests) and what to do manually or add later (integration, frontend).
## Backend unit tests
xUnit project under `portal/tests/Tau.Acuvim.Portal.Tests`, matching the console's test stack (xUnit 2.9.3, Microsoft.NET.Test.Sdk 17.14.1, EF Core InMemory 10.0.8, coverlet 6.0.4).
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal\tests\Tau.Acuvim.Portal.Tests
dotnet test
```
What's covered:
| Suite | What it locks down |
|---|---|
| `CostCalculatorTests` | Period selection by day-of-week + time, overlap-first-wins, default-rate fallback, VAT, fixed charges, `includeFixedMonthlyCharge=false`, timezone conversion (sample inside vs outside the local-time window), empty-samples edge case, null-tariff guard. |
| `ConnectionStringResolverTests` | All four precedence branches: explicit conn string wins; auto-provision in non-Production builds from `TimescaleDb` block; auto-provision in Production throws; no conn string + auto-provision off throws. |
| `TariffValidatorTests` | Tariff-level rules (name, effective dates, non-negative rates, VAT 0100) and per-period rules (name, days, time order, midnight-wrap rejection, negative rate, bad time format). |
| `DayOfWeekFlagTests` | Each weekday maps correctly; `Weekdays`/`Weekends`/`All` compose. |
## What's not covered by code (deliberate)
### Authorization smoke tests
A `WebApplicationFactory<Program>` test that signs in a fake user and hits an admin endpoint would catch role-attribute regressions. Building one cleanly means swapping the EF provider, neutering `MigrateAsync` / `TimescaleBootstrapper` / the NpgSql health check, and stubbing cookie sign-in. That's a fair amount of plumbing for "the policy attribute is wired."
**Add it if you start seeing role bypass regressions.** Skeleton:
```csharp
public class AuthorizationSmokeTests : IClassFixture<WebApplicationFactory<Program>> { … }
```
— with `WebApplicationFactoryConfigureWebHost` swapping `AppDbContext` for InMemory, removing the hosted services that need Timescale, and configuring a test authentication scheme.
### Frontend component tests
Recommend Vitest + React Testing Library + jsdom. Not scaffolded in this repo on purpose — a half-built test runner is worse than none.
When you want them:
```powershell
cd portal/frontend
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8
```
Add to `vite.config.ts`:
```ts
test: { environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], globals: true }
```
High-value first tests:
- `RequireAuth` — renders children when authed, redirects to `/login` when not.
- `RequireRole` — renders 403 panel when role missing, children when present.
- `BrandingProvider` — applies CSS variables and document.title after fetch resolves.
- `PeriodEditor` — toggling day-of-week buttons updates the bitmask correctly (this is the trickiest pure-logic spot in the UI).
## Manual integration scenario
End-to-end smoke test. Run after any phase change.
### 0. Reset
```powershell
cd C:\AcuvimDev\Tau.Acuvim\portal
docker compose down -v # wipe DB + Grafana volumes
docker compose up -d
```
### 1. Migrate + seed
```powershell
cd src/Tau.Acuvim.Portal
dotnet run
```
Expect in the log:
- `Database connection resolved via …`
- `Applied migration '<name>'` (one per pending)
- `TimescaleDB hypertable for monitoring.PowerMeasurements is ready (chunk interval: 7 days)`
- `Seeded default admin admin@example.com`
### 2. Frontend up
```powershell
cd portal/frontend
npm install
npm run dev
```
### 3. Walk the surface
1. Browse `http://localhost:5174/login` → branded card from template defaults.
2. Sign in as `admin@example.com` / `ChangeMe123!`.
3. **Settings → Branding** → change primary colour + footer + upload a small PNG. Save → re-themes everywhere without reload.
4. **Settings → Rates** → create municipality "Cape Town" TZ `Africa/Johannesburg` → add tariff with three periods (peak/standard/off-peak per the README example).
5. **Settings → Users** → create `user@example.com` / `Password1`, not admin. Sign out, log in as user → only **Dashboard** and **Dashboards** in nav; `/admin/sites` shows 403. Sign back in as admin.
6. **Sites** → create "Warehouse A" linked to Cape Town → add device with external ID `ABC0001-MAIN-01`.
7. Ingest via Swagger (`/swagger`) or:
```
POST /api/ingest/measurements
[
{"time":"2026-05-17T10:00:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":12.5,"energyImportedKwh":1000},
{"time":"2026-05-17T10:15:00Z","deviceExternalId":"ABC0001-MAIN-01","activePowerKw":13.1,"energyImportedKwh":1003.3}
]
```
`{accepted:2, rejected:0}`. Re-post → same result, no duplicates (`ON CONFLICT`).
8. `GET /api/measurements?deviceId=<guid>&from=2026-05-17T00:00:00Z&to=2026-05-17T23:59:59Z&bucket=hour` → one bucket with `sampleCount: 2`.
9. **Dashboards** → "Power Overview" iframe loads; device dropdown lists the device; chart renders the two samples.
10. **Settings → App config** → confirm DB host = `timescaledb`, port `5432`, db `power_monitoring`, **no password anywhere**.
### 4. Edges to poke
| Check | Expected |
|---|---|
| Wrong password 6 times | 6th try locks the account for 15 min (Identity lockout). |
| `dotnet run` with `ASPNETCORE_ENVIRONMENT=Production` + default admin password | App refuses to start with explicit error. |
| Restart `dotnet run` against existing DB | "Seeded default admin" line absent (idempotent). Bootstrapper re-confirms Timescale hypertable without error. |
| Delete a site with devices+measurements | Cascade removes everything; UI updates. |
| Create a period with EndTime ≤ StartTime in **Settings → Rates** | Backend 400 with "no midnight wrap" message. |
| `GET /api/admin/config-overview` as non-admin | 403. |
| Upload a 3 MB JPG to branding logo | 400 "Logo must be <= 2 MB". |
## Local dev troubleshooting
- **EF migration error on startup** — usually a schema drift since the last migration. Run `dotnet ef migrations add <Name>` to capture the diff, commit, restart.
- **`TimescaleBootstrapper` complains about hypertable** — the table was already created by a previous run with different settings. Drop the table or recreate the DB volume.
- **`POST /api/ingest/measurements` returns `{accepted: 0, rejected: N}`** — the device's `ExternalId` doesn't match anything in the `monitoring.Devices` table. Create the device first under Sites.
- **Dashboards page shows iframe but no chart** — datasource UID mismatch. Confirm `grafana/provisioning/datasources/timescaledb.yml` sets `uid: timescaledb` and the dashboard JSON's panels reference the same.

View File

@ -0,0 +1,111 @@
# Production stack: one per customer.
# Requires:
# - COMPOSE_PROJECT_NAME=abc0001 (LOWERCASE; Compose v2 rejects uppercase. Use the
# lowercase form of the customer ID — abc0001 for ABC0001.)
# - CUSTOMER_HOST=abc0001.portal.example.com (Traefik routes by this Host)
# - POSTGRES_PASSWORD, GRAFANA_ADMIN_PASSWORD set in .env
# - An external Docker network `traefik-public` created by your Traefik stack
#
# Run with:
# docker compose -f docker-compose.prod.yml --env-file .env up -d
services:
portal:
build: .
image: tau-acuvim-portal:latest
container_name: ${COMPOSE_PROJECT_NAME}_portal
restart: unless-stopped
environment:
- ASPNETCORE_ENVIRONMENT=Production
- Application__PublicUrl=https://${CUSTOMER_HOST}
- Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD}
- Database__AutoProvisionLocalTimescaleDb=false
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail}
- Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword}
- Grafana__BaseUrl=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- Grafana__InternalUrl=http://grafana:3000
- Grafana__EmbedPathPrefix=${Grafana__EmbedPathPrefix:-/grafana}
depends_on:
timescaledb:
condition: service_healthy
volumes:
- portal-keys:/data/keys
- portal-branding:/data/branding
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.rule=Host(`${CUSTOMER_HOST}`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.tls.certresolver=le"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-portal.priority=10"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-portal.loadbalancer.server.port=8080"
timescaledb:
image: timescale/timescaledb:2.17.2-pg16
container_name: ${COMPOSE_PROJECT_NAME}_timescale
restart: unless-stopped
environment:
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- timescale-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"]
interval: 10s
timeout: 5s
retries: 10
grafana:
image: grafana/grafana:11.4.0
container_name: ${COMPOSE_PROJECT_NAME}_grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_SERVER_ROOT_URL=https://${CUSTOMER_HOST}${Grafana__EmbedPathPrefix:-/grafana}
- GF_SERVER_SERVE_FROM_SUB_PATH=true
# PROD AUTH IS NOT WIRED YET (Phase 9 risk).
# Anonymous is OFF so dashboards do NOT serve until you choose:
# (a) Traefik forwardAuth middleware → /api/auth/check
# (b) Grafana auth.proxy (GF_AUTH_PROXY_*) with X-WEBAUTH-USER from portal/Traefik
# (c) Service-account API key + portal-minted render tokens
# See README "Embedding Grafana — production auth options".
- GF_AUTH_ANONYMOUS_ENABLED=false
- GF_USERS_ALLOW_SIGN_UP=false
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
timescaledb:
condition: service_healthy
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.rule=Host(`${CUSTOMER_HOST}`) && PathPrefix(`${Grafana__EmbedPathPrefix:-/grafana}`)"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.entrypoints=websecure"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls=true"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.tls.certresolver=le"
- "traefik.http.routers.${COMPOSE_PROJECT_NAME}-grafana.priority=20"
- "traefik.http.services.${COMPOSE_PROJECT_NAME}-grafana.loadbalancer.server.port=3000"
volumes:
portal-keys:
portal-branding:
timescale-data:
grafana-data:
networks:
traefik-public:
external: true

70
portal/docker-compose.yml Normal file
View File

@ -0,0 +1,70 @@
# Local development stack.
# For production, see docker-compose.prod.yml (Traefik labels, no host ports, no anon Grafana).
services:
portal:
build: .
container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_portal
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
- Database__ConnectionString=Host=timescaledb;Port=5432;Database=${POSTGRES_DB:-power_monitoring};Username=${POSTGRES_USER:-power_user};Password=${POSTGRES_PASSWORD:-change_me_for_local_only}
- Database__AutoProvisionLocalTimescaleDb=false
# In the container the writable volume is /data/branding (Dockerfile chowns it).
# The appsettings.Development.json override of LogoStoragePath is for local `dotnet run`, not Docker.
- WhiteLabel__LogoStoragePath=/data/branding
- Authentication__DefaultAdminEmail=${Authentication__DefaultAdminEmail:-admin@example.com}
- Authentication__DefaultAdminPassword=${Authentication__DefaultAdminPassword:-ChangeMe123!}
- Grafana__BaseUrl=http://localhost:3001
- Grafana__InternalUrl=http://grafana:3000
depends_on:
timescaledb:
condition: service_healthy
volumes:
- portal-keys:/data/keys
- portal-branding:/data/branding
timescaledb:
image: timescale/timescaledb:2.17.2-pg16
container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_timescale
ports:
- "5433:5432"
environment:
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change_me_for_local_only}
volumes:
- timescale-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-power_user} -d ${POSTGRES_DB:-power_monitoring}"]
interval: 5s
timeout: 5s
retries: 10
grafana:
image: grafana/grafana:11.4.0
container_name: ${COMPOSE_PROJECT_NAME:-portal-dev}_grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_SECURITY_ALLOW_EMBEDDING=true
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_USERS_ALLOW_SIGN_UP=false
- POSTGRES_DB=${POSTGRES_DB:-power_monitoring}
- POSTGRES_USER=${POSTGRES_USER:-power_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-change_me_for_local_only}
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
timescaledb:
condition: service_healthy
volumes:
portal-keys:
portal-branding:
timescale-data:
grafana-data:

11
portal/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules/
dist/
.vite/
*.log
*.tsbuildinfo
# Build artefacts of vite.config.ts (we keep the .ts source only)
vite.config.js
vite.config.d.ts
.env
.env.local
.env.*.local

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tau Acuvim Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3282
portal/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
{
"name": "tau-acuvim-portal",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@tanstack/react-query": "^5.62.0",
"antd": "^5.22.0",
"axios": "^1.7.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.0"
}
}

View File

@ -0,0 +1,64 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './hooks/useAuth';
import { BrandingProvider } from './hooks/useBranding';
import { ThemedRoot } from './components/ThemedRoot';
import { RequireAuth } from './components/RequireAuth';
import { RequireRole } from './components/RequireRole';
import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
import { DashboardsPage } from './pages/DashboardsPage';
import { AdminSitesPage } from './pages/AdminSitesPage';
import { SettingsPage } from './pages/SettingsPage';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrandingProvider>
<ThemedRoot>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<AppLayout />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="dashboards" element={<DashboardsPage />} />
<Route
path="admin/sites"
element={
<RequireRole role="Admin">
<AdminSitesPage />
</RequireRole>
}
/>
<Route
path="settings"
element={
<RequireRole role="Admin">
<SettingsPage />
</RequireRole>
}
/>
<Route path="admin/users" element={<Navigate to="/settings" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
</ThemedRoot>
</BrandingProvider>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,31 @@
import { api } from './client';
export interface ConfigOverview {
application: { name: string; environment: string; publicUrl: string };
database: {
provider: string;
host: string;
port: number;
database: string;
migrateOnStartup: boolean;
autoProvisionLocalTimescaleDb: boolean;
resolvedVia: string;
};
grafana: {
baseUrl: string;
internalUrl: string;
embedPathPrefix: string;
embedMode: string;
defaultDashboardUid: string;
authMode: string;
dashboardCount: number;
};
monitoring: { chunkTimeInterval: string; enableHourlyAggregates: boolean };
authentication: { cookieName: string; requireConfirmedEmail: boolean; defaultAdminEmail: string };
build: { assemblyVersion: string; framework: string; startedAtUtc: string };
}
export async function fetchConfigOverview(): Promise<ConfigOverview> {
const { data } = await api.get<ConfigOverview>('/admin/config-overview');
return data;
}

View File

@ -0,0 +1,34 @@
import { api } from './client';
export interface CurrentUser {
email: string;
displayName: string;
roles: string[];
}
export async function login(email: string, password: string): Promise<CurrentUser> {
const { data } = await api.post<CurrentUser>('/auth/login', { email, password });
return data;
}
export async function logout(): Promise<void> {
await api.post('/auth/logout');
}
export async function fetchCurrentUser(): Promise<CurrentUser | null> {
try {
const { data } = await api.get<CurrentUser>('/auth/me');
return data;
} catch (err: unknown) {
if (axiosStatus(err) === 401) return null;
throw err;
}
}
function axiosStatus(err: unknown): number | undefined {
if (typeof err === 'object' && err !== null && 'response' in err) {
const resp = (err as { response?: { status?: number } }).response;
return resp?.status;
}
return undefined;
}

View File

@ -0,0 +1,38 @@
import { api } from './client';
export interface Branding {
applicationName: string;
logoUrl: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
footerText: string;
}
export interface UpdateBrandingPayload {
applicationName: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
footerText: string;
logoUrl?: string;
}
export async function fetchBranding(): Promise<Branding> {
const { data } = await api.get<Branding>('/branding');
return data;
}
export async function updateBranding(payload: UpdateBrandingPayload): Promise<Branding> {
const { data } = await api.put<Branding>('/branding/', payload);
return data;
}
export async function uploadBrandingLogo(file: File): Promise<{ logoUrl: string }> {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<{ logoUrl: string }>('/branding/logo', form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}

View File

@ -0,0 +1,7 @@
import axios from 'axios';
export const api = axios.create({
baseURL: '/api',
withCredentials: true,
headers: { 'Content-Type': 'application/json' },
});

View File

@ -0,0 +1,18 @@
import { api } from './client';
export interface GrafanaDashboard {
uid: string;
title: string;
description: string;
}
export interface GrafanaConfig {
baseUrl: string;
defaultDashboardUid: string;
dashboards: GrafanaDashboard[];
}
export async function fetchGrafanaConfig(): Promise<GrafanaConfig> {
const { data } = await api.get<GrafanaConfig>('/grafana/config');
return data;
}

View File

@ -0,0 +1,104 @@
import { api } from './client';
export interface Municipality {
id: number;
name: string;
timeZoneId: string | null;
isActive: boolean;
tariffCount: number;
}
export interface TariffPeriod {
name: string;
daysOfWeek: number;
startTime: string;
endTime: string;
ratePerKwh: number;
}
export interface TariffSummary {
id: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
defaultRatePerKwh: number;
fixedMonthlyCharge: number;
vatPercentage: number;
isActive: boolean;
periodCount: number;
}
export interface TariffDetail {
id: number;
municipalityId: number;
name: string;
effectiveFrom: string;
effectiveTo: string | null;
defaultRatePerKwh: number;
fixedMonthlyCharge: number;
vatPercentage: number;
isActive: boolean;
periods: TariffPeriod[];
}
export interface UpsertMunicipality {
name: string;
timeZoneId: string | null;
isActive: boolean;
}
export interface UpsertTariff {
name: string;
effectiveFrom: string;
effectiveTo: string | null;
defaultRatePerKwh: number;
fixedMonthlyCharge: number;
vatPercentage: number;
isActive: boolean;
periods: TariffPeriod[];
}
export async function listMunicipalities(): Promise<Municipality[]> {
const { data } = await api.get<Municipality[]>('/rates/municipalities');
return data;
}
export async function createMunicipality(payload: UpsertMunicipality): Promise<Municipality> {
const { data } = await api.post<Municipality>('/admin/rates/municipalities', payload);
return data;
}
export async function updateMunicipality(id: number, payload: UpsertMunicipality): Promise<void> {
await api.put(`/admin/rates/municipalities/${id}`, payload);
}
export async function deleteMunicipality(id: number): Promise<void> {
await api.delete(`/admin/rates/municipalities/${id}`);
}
export async function listTariffs(municipalityId: number): Promise<TariffSummary[]> {
const { data } = await api.get<TariffSummary[]>(`/rates/municipalities/${municipalityId}/tariffs`);
return data;
}
export async function getTariff(tariffId: number): Promise<TariffDetail> {
const { data } = await api.get<TariffDetail>(`/rates/tariffs/${tariffId}`);
return data;
}
export async function createTariff(municipalityId: number, payload: UpsertTariff): Promise<TariffDetail> {
const { data } = await api.post<TariffDetail>(
`/admin/rates/municipalities/${municipalityId}/tariffs`,
payload,
);
return data;
}
export async function updateTariff(tariffId: number, payload: UpsertTariff): Promise<TariffDetail> {
const { data } = await api.put<TariffDetail>(`/admin/rates/tariffs/${tariffId}`, payload);
return data;
}
export async function deleteTariff(tariffId: number): Promise<void> {
await api.delete(`/admin/rates/tariffs/${tariffId}`);
}

View File

@ -0,0 +1,69 @@
import { api } from './client';
export interface Site {
id: string;
name: string;
address: string | null;
municipalityId: number | null;
isActive: boolean;
deviceCount: number;
}
export interface Device {
id: string;
siteId: string;
name: string;
externalId: string;
description: string | null;
isActive: boolean;
}
export interface UpsertSite {
name: string;
address: string | null;
municipalityId: number | null;
isActive: boolean;
}
export interface UpsertDevice {
name: string;
externalId: string;
description: string | null;
isActive: boolean;
}
export async function listSites(): Promise<Site[]> {
const { data } = await api.get<Site[]>('/sites/');
return data;
}
export async function listSiteDevices(siteId: string): Promise<Device[]> {
const { data } = await api.get<Device[]>(`/sites/${siteId}/devices`);
return data;
}
export async function createSite(payload: UpsertSite): Promise<Site> {
const { data } = await api.post<Site>('/admin/sites/', payload);
return data;
}
export async function updateSite(id: string, payload: UpsertSite): Promise<void> {
await api.put(`/admin/sites/${id}`, payload);
}
export async function deleteSite(id: string): Promise<void> {
await api.delete(`/admin/sites/${id}`);
}
export async function createDevice(siteId: string, payload: UpsertDevice): Promise<Device> {
const { data } = await api.post<Device>(`/admin/sites/${siteId}/devices`, payload);
return data;
}
export async function updateDevice(deviceId: string, payload: UpsertDevice): Promise<void> {
await api.put(`/admin/sites/devices/${deviceId}`, payload);
}
export async function deleteDevice(deviceId: string): Promise<void> {
await api.delete(`/admin/sites/devices/${deviceId}`);
}

View File

@ -0,0 +1,45 @@
import { api } from './client';
export interface UserListItem {
id: string;
email: string;
displayName: string;
isActive: boolean;
createdAt: string;
roles: string[];
}
export interface CreateUserPayload {
email: string;
displayName: string;
password: string;
isAdmin: boolean;
}
export interface UpdateUserPayload {
displayName: string;
isActive: boolean;
isAdmin: boolean;
}
export async function listUsers(): Promise<UserListItem[]> {
const { data } = await api.get<UserListItem[]>('/admin/users/');
return data;
}
export async function createUser(payload: CreateUserPayload): Promise<UserListItem> {
const { data } = await api.post<UserListItem>('/admin/users/', payload);
return data;
}
export async function updateUser(id: string, payload: UpdateUserPayload): Promise<void> {
await api.put(`/admin/users/${id}`, payload);
}
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
await api.post(`/admin/users/${id}/reset-password`, { newPassword });
}
export async function deleteUser(id: string): Promise<void> {
await api.delete(`/admin/users/${id}`);
}

View File

@ -0,0 +1,23 @@
import { Navigate, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Spin } from 'antd';
import { useAuth } from '../hooks/useAuth';
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { Result } from 'antd';
import { useAuth } from '../hooks/useAuth';
export function RequireRole({ role, children }: { role: string; children: ReactNode }) {
const { user } = useAuth();
if (!user || !user.roles.includes(role)) {
return (
<Result
status="403"
title="403"
subTitle="You do not have permission to view this page."
/>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
import { ConfigProvider } from 'antd';
import { useBranding } from '../hooks/useBranding';
export function ThemedRoot({ children }: { children: ReactNode }) {
const { branding } = useBranding();
return (
<ConfigProvider
theme={{
token: {
colorPrimary: branding.accentColor,
colorInfo: branding.accentColor,
},
}}
>
{children}
</ConfigProvider>
);
}

View File

@ -0,0 +1,89 @@
import { Layout, Menu, Button, Typography, Space } from 'antd';
import {
DashboardOutlined, SettingOutlined, LogoutOutlined,
LineChartOutlined, ApartmentOutlined,
} from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../../hooks/useAuth';
import { useBranding } from '../../hooks/useBranding';
const { Header, Sider, Content, Footer } = Layout;
const { Text } = Typography;
export function AppLayout() {
const { user, logout } = useAuth();
const { branding } = useBranding();
const navigate = useNavigate();
const location = useLocation();
const isAdmin = user?.roles.includes('Admin') ?? false;
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const items = [
{ key: '/', icon: <DashboardOutlined />, label: 'Dashboard' },
{ key: '/dashboards', icon: <LineChartOutlined />, label: 'Dashboards' },
...(isAdmin
? [
{ key: '/admin/sites', icon: <ApartmentOutlined />, label: 'Sites' },
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
]
: []),
];
return (
<Layout style={{ minHeight: '100vh' }}>
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: branding.primaryColor,
paddingInline: 24,
}}
>
<Space>
{branding.logoUrl && (
<img
src={branding.logoUrl}
alt=""
style={{ maxHeight: 36, background: 'rgba(255,255,255,0.1)', padding: 2, borderRadius: 2 }}
/>
)}
<Text strong style={{ color: '#fff', fontSize: 18 }}>
{branding.applicationName}
</Text>
</Space>
<Space>
<Text style={{ color: '#cbd5e1' }}>{user?.displayName ?? user?.email}</Text>
<Button icon={<LogoutOutlined />} onClick={handleLogout} type="text" style={{ color: '#fff' }}>
Sign out
</Button>
</Space>
</Header>
<Layout>
<Sider width={220} style={{ background: '#fff' }}>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
onClick={(e) => navigate(e.key)}
style={{ height: '100%', borderRight: 0 }}
items={items}
/>
</Sider>
<Layout>
<Content style={{ padding: 24, background: '#f5f5f5' }}>
<Outlet />
</Content>
{branding.footerText && (
<Footer style={{ textAlign: 'center', background: 'transparent', color: '#888' }}>
{branding.footerText}
</Footer>
)}
</Layout>
</Layout>
</Layout>
);
}

View File

@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { Form, Input, Button, Row, Col, Card, Upload, ColorPicker, Space, message, Typography } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import { UploadOutlined, SaveOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
fetchBranding,
updateBranding,
uploadBrandingLogo,
type Branding,
type UpdateBrandingPayload,
} from '../../api/branding';
const { Text } = Typography;
interface FormShape {
applicationName: string;
primaryColor: string;
secondaryColor: string;
accentColor: string;
footerText: string;
}
export function BrandingForm() {
const qc = useQueryClient();
const [form] = Form.useForm<FormShape>();
const [preview, setPreview] = useState<Branding | null>(null);
const { data: branding, isLoading } = useQuery({ queryKey: ['branding'], queryFn: fetchBranding });
useEffect(() => {
if (!branding) return;
form.setFieldsValue({
applicationName: branding.applicationName,
primaryColor: branding.primaryColor,
secondaryColor: branding.secondaryColor,
accentColor: branding.accentColor,
footerText: branding.footerText,
});
setPreview(branding);
}, [branding, form]);
const saveMut = useMutation({
mutationFn: (payload: UpdateBrandingPayload) => updateBranding(payload),
onSuccess: (updated) => {
qc.setQueryData(['branding'], updated);
message.success('Branding saved');
},
onError: () => message.error('Save failed'),
});
const uploadMut = useMutation({
mutationFn: (file: File) => uploadBrandingLogo(file),
onSuccess: ({ logoUrl }) => {
qc.invalidateQueries({ queryKey: ['branding'] });
setPreview((p) => (p ? { ...p, logoUrl } : p));
message.success('Logo uploaded');
},
onError: () => message.error('Logo upload failed'),
});
const handleValuesChange = (_: Partial<FormShape>, all: FormShape) => {
setPreview((p) => (p ? { ...p, ...all } : p));
};
const handleSave = (values: FormShape) => {
saveMut.mutate({
applicationName: values.applicationName,
primaryColor: values.primaryColor,
secondaryColor: values.secondaryColor,
accentColor: values.accentColor,
footerText: values.footerText,
});
};
const beforeUpload = (file: UploadFile) => {
if (file instanceof File) uploadMut.mutate(file);
else if ('originFileObj' in file && file.originFileObj) uploadMut.mutate(file.originFileObj as File);
return false;
};
return (
<Row gutter={24}>
<Col xs={24} md={14}>
<Form<FormShape>
form={form}
layout="vertical"
onFinish={handleSave}
onValuesChange={handleValuesChange}
disabled={isLoading}
>
<Form.Item
name="applicationName"
label="Application name"
rules={[{ required: true, message: 'Application name is required' }]}
>
<Input />
</Form.Item>
<Form.Item label="Logo">
<Space direction="vertical" style={{ width: '100%' }}>
{preview?.logoUrl && (
<img
src={preview.logoUrl}
alt="Current logo"
style={{ maxHeight: 64, maxWidth: 200, background: '#f5f5f5', padding: 8, borderRadius: 4 }}
/>
)}
<Upload
accept="image/png,image/jpeg,image/svg+xml,image/webp"
maxCount={1}
showUploadList={false}
beforeUpload={beforeUpload}
>
<Button icon={<UploadOutlined />} loading={uploadMut.isPending}>
Upload new logo
</Button>
</Upload>
<Text type="secondary">PNG / JPG / SVG / WEBP, max 2 MB.</Text>
</Space>
</Form.Item>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="primaryColor" label="Primary" getValueFromEvent={hexFromPicker}>
<ColorPicker showText format="hex" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="secondaryColor" label="Secondary" getValueFromEvent={hexFromPicker}>
<ColorPicker showText format="hex" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="accentColor" label="Accent" getValueFromEvent={hexFromPicker}>
<ColorPicker showText format="hex" />
</Form.Item>
</Col>
</Row>
<Form.Item name="footerText" label="Footer text">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<SaveOutlined />}>
Save branding
</Button>
</Form.Item>
</Form>
</Col>
<Col xs={24} md={10}>
<Card title="Preview" size="small">
<div
style={{
background: preview?.primaryColor ?? '#1f2937',
color: '#fff',
padding: 12,
borderRadius: 4,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{preview?.logoUrl && (
<img src={preview.logoUrl} alt="" style={{ maxHeight: 28, background: '#fff', padding: 2, borderRadius: 2 }} />
)}
<strong>{preview?.applicationName || 'Application name'}</strong>
</div>
<div style={{ background: preview?.secondaryColor ?? '#374151', color: '#fff', padding: 8, marginTop: 4 }}>
Sidebar / secondary surface
</div>
<Button type="primary" style={{ marginTop: 12 }}>
Accent button
</Button>
{preview?.footerText && (
<div style={{ marginTop: 12, fontSize: 12, color: '#888' }}>{preview.footerText}</div>
)}
</Card>
</Col>
</Row>
);
}
function hexFromPicker(value: unknown): string {
if (typeof value === 'string') return value;
if (value && typeof value === 'object' && 'toHexString' in value) {
return (value as { toHexString: () => string }).toHexString();
}
return '';
}

View File

@ -0,0 +1,85 @@
import { Card, Descriptions, Alert, Typography, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { fetchConfigOverview } from '../../api/adminConfig';
const { Text } = Typography;
export function ConfigOverviewCard() {
const { data, isLoading, error } = useQuery({
queryKey: ['admin-config-overview'],
queryFn: fetchConfigOverview,
});
if (error) return <Alert type="error" message="Failed to load configuration overview" />;
return (
<Card loading={isLoading} title="Effective configuration (read-only)">
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Secrets are not shown."
description="Passwords, connection strings, and signing keys are intentionally omitted. To change a value, edit appsettings.Local.json or an env var and restart the container."
/>
{data && (
<>
<Descriptions title="Application" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Name">{data.application.name}</Descriptions.Item>
<Descriptions.Item label="Environment">
<Tag color={data.application.environment === 'Production' ? 'red' : 'blue'}>
{data.application.environment}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Public URL" span={2}>{data.application.publicUrl}</Descriptions.Item>
</Descriptions>
<Descriptions title="Database" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Provider">{data.database.provider}</Descriptions.Item>
<Descriptions.Item label="Resolved via">
<Text code>{data.database.resolvedVia}</Text>
</Descriptions.Item>
<Descriptions.Item label="Host">{data.database.host}</Descriptions.Item>
<Descriptions.Item label="Port">{data.database.port}</Descriptions.Item>
<Descriptions.Item label="Database">{data.database.database}</Descriptions.Item>
<Descriptions.Item label="Migrate on startup">
{data.database.migrateOnStartup ? <Tag color="green">Yes</Tag> : <Tag color="red">No</Tag>}
</Descriptions.Item>
<Descriptions.Item label="Auto-provision local Timescale" span={2}>
{data.database.autoProvisionLocalTimescaleDb ? <Tag>Yes</Tag> : <Tag color="green">No</Tag>}
</Descriptions.Item>
</Descriptions>
<Descriptions title="Grafana" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Base URL" span={2}>{data.grafana.baseUrl}</Descriptions.Item>
<Descriptions.Item label="Internal URL" span={2}>{data.grafana.internalUrl}</Descriptions.Item>
<Descriptions.Item label="Embed path prefix">{data.grafana.embedPathPrefix}</Descriptions.Item>
<Descriptions.Item label="Embed mode">{data.grafana.embedMode}</Descriptions.Item>
<Descriptions.Item label="Auth mode">{data.grafana.authMode}</Descriptions.Item>
<Descriptions.Item label="Default dashboard UID">{data.grafana.defaultDashboardUid || '(unset)'}</Descriptions.Item>
<Descriptions.Item label="Dashboards configured" span={2}>{data.grafana.dashboardCount}</Descriptions.Item>
</Descriptions>
<Descriptions title="Monitoring" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Chunk time interval">{data.monitoring.chunkTimeInterval}</Descriptions.Item>
<Descriptions.Item label="Hourly aggregates">
{data.monitoring.enableHourlyAggregates ? <Tag color="orange">Flag set (not implemented)</Tag> : 'No'}
</Descriptions.Item>
</Descriptions>
<Descriptions title="Authentication" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Cookie name">{data.authentication.cookieName}</Descriptions.Item>
<Descriptions.Item label="Require confirmed email">{data.authentication.requireConfirmedEmail ? 'Yes' : 'No'}</Descriptions.Item>
<Descriptions.Item label="Default admin email" span={2}>{data.authentication.defaultAdminEmail}</Descriptions.Item>
</Descriptions>
<Descriptions title="Build" column={2} size="small" bordered>
<Descriptions.Item label="Assembly version">{data.build.assemblyVersion}</Descriptions.Item>
<Descriptions.Item label=".NET runtime">{data.build.framework}</Descriptions.Item>
<Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item>
</Descriptions>
</>
)}
</Card>
);
}

View File

@ -0,0 +1,81 @@
import { Card, Descriptions, Table, Typography, Button, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { fetchGrafanaConfig, type GrafanaDashboard } from '../../api/grafana';
const { Text, Paragraph } = Typography;
interface DashboardRow extends GrafanaDashboard {
url: string;
}
export function GrafanaInfoCard() {
const { data, isLoading } = useQuery({ queryKey: ['grafana-config'], queryFn: fetchGrafanaConfig });
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const rows: DashboardRow[] = (data?.dashboards ?? []).map((d) => ({
...d,
url: baseUrl ? `${baseUrl}/d/${encodeURIComponent(d.uid)}?orgId=1&kiosk=tv&theme=light` : '',
}));
const copy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
message.success('Copied');
} catch {
message.error('Copy failed');
}
};
const columns: ColumnsType<DashboardRow> = [
{ title: 'Title', dataIndex: 'title', key: 'title' },
{ title: 'UID', dataIndex: 'uid', key: 'uid' },
{ title: 'Description', dataIndex: 'description', key: 'desc' },
{
title: 'Embed URL',
dataIndex: 'url',
key: 'url',
render: (url: string) => (
<Space>
<Text code style={{ fontSize: 11 }}>{url}</Text>
<Button size="small" icon={<CopyOutlined />} onClick={() => copy(url)} />
</Space>
),
},
];
return (
<Card loading={isLoading}>
<Descriptions title="Grafana" column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Base URL">
<Space>
<Text code>{data?.baseUrl}</Text>
{data?.baseUrl && (
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(data.baseUrl, '_blank')}>
Open
</Button>
)}
</Space>
</Descriptions.Item>
<Descriptions.Item label="Default dashboard UID">
<Text code>{data?.defaultDashboardUid || '(unset)'}</Text>
</Descriptions.Item>
<Descriptions.Item label="Dashboards configured">{data?.dashboards.length ?? 0}</Descriptions.Item>
</Descriptions>
<Paragraph type="secondary" style={{ fontSize: 12 }}>
Add dashboards by dropping JSON into <Text code>grafana/dashboards/</Text> and adding a matching entry
to <Text code>Grafana.Dashboards</Text> in configuration.
</Paragraph>
<Table<DashboardRow>
rowKey="uid"
size="small"
columns={columns}
dataSource={rows}
pagination={false}
/>
</Card>
);
}

View File

@ -0,0 +1,245 @@
import { useState } from 'react';
import {
Table, Button, Space, Modal, Input, Switch, Tag, Popconfirm, Form, Typography, message, Card,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listMunicipalities, createMunicipality, updateMunicipality, deleteMunicipality,
listTariffs, getTariff, deleteTariff,
type Municipality, type TariffSummary, type UpsertMunicipality, type TariffDetail,
} from '../../../api/rates';
import { TariffDrawer } from './TariffDrawer';
const { Text } = Typography;
interface MuniFormShape {
name: string;
timeZoneId: string;
isActive: boolean;
}
type DrawerMode =
| { kind: 'create'; municipalityId: number }
| { kind: 'edit'; tariff: TariffDetail };
export function MunicipalityList() {
const qc = useQueryClient();
const [muniModal, setMuniModal] = useState<{ open: boolean; editing: Municipality | null }>({
open: false,
editing: null,
});
const [muniForm] = Form.useForm<MuniFormShape>();
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
const [drawerMode, setDrawerMode] = useState<DrawerMode | null>(null);
const { data: munis = [], isLoading } = useQuery({
queryKey: ['municipalities'],
queryFn: listMunicipalities,
});
const createMut = useMutation({
mutationFn: (payload: UpsertMunicipality) => createMunicipality(payload),
onSuccess: () => { message.success('Created'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
onError: () => message.error('Create failed'),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: UpsertMunicipality }) => updateMunicipality(id, payload),
onSuccess: () => { message.success('Updated'); closeMuni(); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
onError: () => message.error('Update failed'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => deleteMunicipality(id),
onSuccess: () => { message.success('Deleted'); qc.invalidateQueries({ queryKey: ['municipalities'] }); },
onError: () => message.error('Delete failed (any tariffs are removed with it)'),
});
const deleteTariffMut = useMutation({
mutationFn: ({ municipalityId, tariffId }: { municipalityId: number; tariffId: number }) =>
deleteTariff(tariffId).then(() => municipalityId),
onSuccess: (municipalityId) => {
message.success('Tariff deleted');
qc.invalidateQueries({ queryKey: ['tariffs', municipalityId] });
qc.invalidateQueries({ queryKey: ['municipalities'] });
},
onError: () => message.error('Delete failed'),
});
const openCreateMuni = () => {
muniForm.resetFields();
muniForm.setFieldsValue({ name: '', timeZoneId: '', isActive: true });
setMuniModal({ open: true, editing: null });
};
const openEditMuni = (m: Municipality) => {
muniForm.setFieldsValue({ name: m.name, timeZoneId: m.timeZoneId ?? '', isActive: m.isActive });
setMuniModal({ open: true, editing: m });
};
const closeMuni = () => setMuniModal({ open: false, editing: null });
const onMuniSubmit = (values: MuniFormShape) => {
const payload: UpsertMunicipality = {
name: values.name.trim(),
timeZoneId: values.timeZoneId.trim() || null,
isActive: values.isActive,
};
if (muniModal.editing) updateMut.mutate({ id: muniModal.editing.id, payload });
else createMut.mutate(payload);
};
const openEditTariff = async (tariffId: number) => {
const detail = await getTariff(tariffId);
setDrawerMode({ kind: 'edit', tariff: detail });
};
const columns: ColumnsType<Municipality> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Time zone', dataIndex: 'timeZoneId', key: 'tz', render: (v: string | null) => v ?? <Text type="secondary">UTC</Text> },
{ title: 'Tariffs', dataIndex: 'tariffCount', key: 'count' },
{
title: 'Active',
dataIndex: 'isActive',
key: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
},
{
title: 'Actions',
key: 'actions',
render: (_, m) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditMuni(m)}>Edit</Button>
<Popconfirm
title={`Delete ${m.name}? All its tariffs will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(m.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<Space style={{ marginBottom: 16, justifyContent: 'space-between', width: '100%' }}>
<Text type="secondary">Configure municipalities and their tariffs. Expand a row to see tariffs.</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateMuni}>New municipality</Button>
</Space>
<Table<Municipality>
rowKey="id"
columns={columns}
dataSource={munis}
loading={isLoading}
pagination={false}
expandable={{
expandedRowKeys: expandedKeys,
onExpandedRowsChange: (keys) => setExpandedKeys(keys as number[]),
expandedRowRender: (m) => (
<TariffSubTable
municipality={m}
onCreate={() => setDrawerMode({ kind: 'create', municipalityId: m.id })}
onEdit={openEditTariff}
onDelete={(tariffId) => deleteTariffMut.mutate({ municipalityId: m.id, tariffId })}
/>
),
}}
/>
<Modal
title={muniModal.editing ? `Edit ${muniModal.editing.name}` : 'New municipality'}
open={muniModal.open}
onCancel={closeMuni}
onOk={() => muniForm.submit()}
confirmLoading={createMut.isPending || updateMut.isPending}
>
<Form<MuniFormShape> form={muniForm} layout="vertical" onFinish={onMuniSubmit} requiredMark={false}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
<Form.Item
name="timeZoneId"
label="Time zone (IANA, optional)"
tooltip="e.g. Africa/Johannesburg. Empty = UTC."
>
<Input placeholder="Africa/Johannesburg" />
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<TariffDrawer
open={drawerMode !== null}
mode={drawerMode}
onClose={() => setDrawerMode(null)}
/>
</>
);
}
function TariffSubTable({
municipality,
onCreate,
onEdit,
onDelete,
}: {
municipality: Municipality;
onCreate: () => void;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
}) {
const { data: tariffs = [], isLoading } = useQuery({
queryKey: ['tariffs', municipality.id],
queryFn: () => listTariffs(municipality.id),
});
const columns: ColumnsType<TariffSummary> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Effective', key: 'effective', render: (_, t) => `${t.effectiveFrom}${t.effectiveTo ? ' → ' + t.effectiveTo : ' →'}` },
{ title: 'Default rate', dataIndex: 'defaultRatePerKwh', key: 'rate', render: (v: number) => v.toFixed(4) },
{ title: 'Fixed', dataIndex: 'fixedMonthlyCharge', key: 'fixed', render: (v: number) => v.toFixed(2) },
{ title: 'VAT %', dataIndex: 'vatPercentage', key: 'vat', render: (v: number) => v.toFixed(2) },
{ title: 'Periods', dataIndex: 'periodCount', key: 'periods' },
{
title: 'Active',
dataIndex: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>),
},
{
title: 'Actions',
key: 'actions',
render: (_, t) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(t.id)}>Edit</Button>
<Popconfirm
title={`Delete tariff "${t.name}"?`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => onDelete(t.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card size="small" style={{ background: '#fafafa' }}>
<Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New tariff</Button>
</Space>
<Table<TariffSummary>
rowKey="id"
size="small"
columns={columns}
dataSource={tariffs}
loading={isLoading}
pagination={false}
/>
</Card>
);
}

View File

@ -0,0 +1,119 @@
import { Button, Input, InputNumber, Space, TimePicker, Typography, Tooltip } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { TariffPeriod } from '../../../api/rates';
const { Text } = Typography;
const DAY_OPTIONS: Array<{ label: string; value: number }> = [
{ label: 'M', value: 1 },
{ label: 'T', value: 2 },
{ label: 'W', value: 4 },
{ label: 'T', value: 8 },
{ label: 'F', value: 16 },
{ label: 'S', value: 32 },
{ label: 'S', value: 64 },
];
interface Props {
value: TariffPeriod[];
onChange: (next: TariffPeriod[]) => void;
}
export function PeriodEditor({ value, onChange }: Props) {
const update = (idx: number, patch: Partial<TariffPeriod>) => {
const next = value.map((p, i) => (i === idx ? { ...p, ...patch } : p));
onChange(next);
};
const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
const add = () =>
onChange([
...value,
{ name: 'New period', daysOfWeek: 31, startTime: '08:00', endTime: '17:00', ratePerKwh: 0 },
]);
const toggleDay = (idx: number, dayValue: number) => {
const current = value[idx].daysOfWeek;
const next = (current & dayValue) !== 0 ? current & ~dayValue : current | dayValue;
update(idx, { daysOfWeek: next });
};
return (
<div>
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value.length === 0 && (
<Text type="secondary">No periods. The tariff's default rate applies to all time.</Text>
)}
{value.map((p, idx) => (
<div
key={idx}
style={{
border: '1px solid #f0f0f0',
padding: 12,
borderRadius: 4,
display: 'grid',
gridTemplateColumns: '1.4fr 2fr 1.2fr 1.2fr 1fr auto',
gap: 8,
alignItems: 'center',
}}
>
<Input
placeholder="Period name"
value={p.name}
onChange={(e) => update(idx, { name: e.target.value })}
/>
<Space size={2} wrap>
{DAY_OPTIONS.map((d, dIdx) => {
const active = (p.daysOfWeek & d.value) !== 0;
return (
<Tooltip key={dIdx} title={['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][dIdx]}>
<Button
size="small"
type={active ? 'primary' : 'default'}
onClick={() => toggleDay(idx, d.value)}
style={{ width: 28, padding: 0 }}
>
{d.label}
</Button>
</Tooltip>
);
})}
</Space>
<TimePicker
value={dayjs(p.startTime, 'HH:mm')}
format="HH:mm"
minuteStep={5}
allowClear={false}
onChange={(v) => v && update(idx, { startTime: v.format('HH:mm') })}
/>
<TimePicker
value={dayjs(p.endTime, 'HH:mm')}
format="HH:mm"
minuteStep={5}
allowClear={false}
onChange={(v) => v && update(idx, { endTime: v.format('HH:mm') })}
/>
<InputNumber
min={0}
step={0.01}
precision={4}
value={p.ratePerKwh}
onChange={(v) => update(idx, { ratePerKwh: Number(v ?? 0) })}
addonAfter="/kWh"
/>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => remove(idx)} />
</div>
))}
<Button icon={<PlusOutlined />} onClick={add}>
Add period
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>
Times are local to the municipality's time zone. No midnight wrap to model 22:0006:00,
add two rows (22:0023:59 and 00:0006:00).
</Text>
</Space>
</div>
);
}

View File

@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import {
Drawer, Form, Input, InputNumber, Switch, DatePicker, Row, Col, Button, Space, Alert, Typography,
} from 'antd';
import dayjs from 'dayjs';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTariff, updateTariff, type TariffDetail, type TariffPeriod, type UpsertTariff } from '../../../api/rates';
import { PeriodEditor } from './PeriodEditor';
const { Title } = Typography;
type Mode =
| { kind: 'create'; municipalityId: number }
| { kind: 'edit'; tariff: TariffDetail };
interface Props {
open: boolean;
mode: Mode | null;
onClose: () => void;
}
interface FormShape {
name: string;
effectiveFrom: dayjs.Dayjs;
effectiveTo: dayjs.Dayjs | null;
defaultRatePerKwh: number;
fixedMonthlyCharge: number;
vatPercentage: number;
isActive: boolean;
}
export function TariffDrawer({ open, mode, onClose }: Props) {
const qc = useQueryClient();
const [form] = Form.useForm<FormShape>();
const [periods, setPeriods] = useState<TariffPeriod[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !mode) return;
setError(null);
if (mode.kind === 'edit') {
form.setFieldsValue({
name: mode.tariff.name,
effectiveFrom: dayjs(mode.tariff.effectiveFrom),
effectiveTo: mode.tariff.effectiveTo ? dayjs(mode.tariff.effectiveTo) : null,
defaultRatePerKwh: mode.tariff.defaultRatePerKwh,
fixedMonthlyCharge: mode.tariff.fixedMonthlyCharge,
vatPercentage: mode.tariff.vatPercentage,
isActive: mode.tariff.isActive,
});
setPeriods(mode.tariff.periods);
} else {
form.resetFields();
form.setFieldsValue({
effectiveFrom: dayjs(),
defaultRatePerKwh: 0,
fixedMonthlyCharge: 0,
vatPercentage: 15,
isActive: true,
});
setPeriods([]);
}
}, [open, mode, form]);
const muniId = mode?.kind === 'create' ? mode.municipalityId : mode?.tariff.municipalityId;
const saveMut = useMutation({
mutationFn: (payload: UpsertTariff) =>
mode?.kind === 'edit'
? updateTariff(mode.tariff.id, payload)
: createTariff((mode as { municipalityId: number }).municipalityId, payload),
onSuccess: () => {
if (muniId !== undefined) qc.invalidateQueries({ queryKey: ['tariffs', muniId] });
qc.invalidateQueries({ queryKey: ['municipalities'] });
onClose();
},
onError: (err: unknown) => setError(extractError(err)),
});
const handleSubmit = (values: FormShape) => {
setError(null);
saveMut.mutate({
name: values.name,
effectiveFrom: values.effectiveFrom.format('YYYY-MM-DD'),
effectiveTo: values.effectiveTo ? values.effectiveTo.format('YYYY-MM-DD') : null,
defaultRatePerKwh: values.defaultRatePerKwh,
fixedMonthlyCharge: values.fixedMonthlyCharge,
vatPercentage: values.vatPercentage,
isActive: values.isActive,
periods,
});
};
return (
<Drawer
title={mode?.kind === 'edit' ? `Edit ${mode.tariff.name}` : 'New tariff'}
width={760}
open={open}
onClose={onClose}
destroyOnClose
extra={
<Space>
<Button onClick={onClose}>Cancel</Button>
<Button type="primary" loading={saveMut.isPending} onClick={() => form.submit()}>
Save
</Button>
</Space>
}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form<FormShape> form={form} layout="vertical" onFinish={handleSubmit} requiredMark={false}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="Tariff name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="effectiveFrom" label="Effective from" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="effectiveTo" label="Effective to (optional)">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item
name="defaultRatePerKwh"
label="Default rate (per kWh)"
rules={[{ required: true, message: 'Required' }]}
>
<InputNumber style={{ width: '100%' }} min={0} step={0.01} precision={4} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="fixedMonthlyCharge"
label="Fixed monthly charge"
rules={[{ required: true, message: 'Required' }]}
>
<InputNumber style={{ width: '100%' }} min={0} step={0.01} precision={2} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="vatPercentage"
label="VAT %"
rules={[{ required: true, message: 'Required' }]}
>
<InputNumber style={{ width: '100%' }} min={0} max={100} step={0.1} precision={2} />
</Form.Item>
</Col>
</Row>
<Title level={5} style={{ marginTop: 8 }}>Time-of-use periods</Title>
<PeriodEditor value={periods} onChange={setPeriods} />
</Form>
</Drawer>
);
}
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string } } }).response?.data;
if (data?.error) return data.error;
}
return 'Save failed.';
}

View File

@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { Modal, Form, Input, Switch } from 'antd';
import type { Device, UpsertDevice } from '../../api/sites';
interface Props {
open: boolean;
editing: Device | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: UpsertDevice) => void;
}
interface FormShape {
name: string;
externalId: string;
description?: string;
isActive: boolean;
}
export function DeviceFormModal({ open, editing, submitting, onClose, onSubmit }: Props) {
const [form] = Form.useForm<FormShape>();
useEffect(() => {
if (!open) return;
if (editing) {
form.setFieldsValue({
name: editing.name,
externalId: editing.externalId,
description: editing.description ?? undefined,
isActive: editing.isActive,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true });
}
}, [open, editing, form]);
const handleFinish = (values: FormShape) => {
onSubmit({
name: values.name.trim(),
externalId: values.externalId.trim(),
description: values.description?.trim() || null,
isActive: values.isActive,
});
};
return (
<Modal
title={editing ? `Edit ${editing.name}` : 'New device'}
open={open}
onCancel={onClose}
onOk={() => form.submit()}
confirmLoading={submitting}
>
<Form<FormShape> form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
<Form.Item
name="externalId"
label="External ID"
tooltip="The identifier the data source uses, e.g. ESP32 device ID."
rules={[{ required: true, message: 'Required' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { Modal, Form, Input, Switch, Select } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { listMunicipalities } from '../../api/rates';
import type { Site, UpsertSite } from '../../api/sites';
interface Props {
open: boolean;
editing: Site | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: UpsertSite) => void;
}
interface FormShape {
name: string;
address?: string;
municipalityId?: number;
isActive: boolean;
}
export function SiteFormModal({ open, editing, submitting, onClose, onSubmit }: Props) {
const [form] = Form.useForm<FormShape>();
const { data: munis = [] } = useQuery({
queryKey: ['municipalities'],
queryFn: listMunicipalities,
enabled: open,
});
useEffect(() => {
if (!open) return;
if (editing) {
form.setFieldsValue({
name: editing.name,
address: editing.address ?? undefined,
municipalityId: editing.municipalityId ?? undefined,
isActive: editing.isActive,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true });
}
}, [open, editing, form]);
const handleFinish = (values: FormShape) => {
onSubmit({
name: values.name.trim(),
address: values.address?.trim() || null,
municipalityId: values.municipalityId ?? null,
isActive: values.isActive,
});
};
return (
<Modal
title={editing ? `Edit ${editing.name}` : 'New site'}
open={open}
onCancel={onClose}
onOk={() => form.submit()}
confirmLoading={submitting}
>
<Form<FormShape> form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Required' }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="Address">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 3 }} />
</Form.Item>
<Form.Item name="municipalityId" label="Municipality">
<Select
allowClear
placeholder="None (no tariff context)"
options={munis.map((m) => ({ label: m.name, value: m.id }))}
/>
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { Drawer, Form, Input, Switch, Button, Space, Alert } from 'antd';
import type { UserListItem, CreateUserPayload, UpdateUserPayload } from '../../api/users';
type Mode =
| { kind: 'create' }
| { kind: 'edit'; user: UserListItem };
interface Props {
open: boolean;
mode: Mode | null;
submitting: boolean;
error?: string | null;
onClose: () => void;
onSubmit: (values: CreateUserPayload | UpdateUserPayload) => Promise<void>;
}
export function UserFormDrawer({ open, mode, submitting, error, onClose, onSubmit }: Props) {
const [form] = Form.useForm();
useEffect(() => {
if (!open) return;
if (mode?.kind === 'edit') {
form.setFieldsValue({
email: mode.user.email,
displayName: mode.user.displayName,
isActive: mode.user.isActive,
isAdmin: mode.user.roles.includes('Admin'),
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, isAdmin: false });
}
}, [open, mode, form]);
const isEdit = mode?.kind === 'edit';
const handleFinish = async (values: Record<string, unknown>) => {
if (isEdit) {
await onSubmit({
displayName: String(values.displayName ?? ''),
isActive: Boolean(values.isActive),
isAdmin: Boolean(values.isAdmin),
});
} else {
await onSubmit({
email: String(values.email ?? '').trim(),
displayName: String(values.displayName ?? ''),
password: String(values.password ?? ''),
isAdmin: Boolean(values.isAdmin),
});
}
};
return (
<Drawer
title={isEdit ? `Edit ${mode?.user.email}` : 'Create user'}
width={420}
open={open}
onClose={onClose}
destroyOnClose
extra={
<Space>
<Button onClick={onClose}>Cancel</Button>
<Button type="primary" loading={submitting} onClick={() => form.submit()}>
{isEdit ? 'Save' : 'Create'}
</Button>
</Space>
}
>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item
name="email"
label="Email"
rules={[{ required: true, type: 'email', message: 'A valid email is required' }]}
>
<Input disabled={isEdit} autoComplete="off" />
</Form.Item>
<Form.Item name="displayName" label="Display name">
<Input />
</Form.Item>
{!isEdit && (
<Form.Item
name="password"
label="Password"
rules={[{ required: true, min: 8, message: 'Min 8 characters with upper, lower, and digit' }]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
)}
<Form.Item name="isAdmin" label="Administrator" valuePropName="checked">
<Switch />
</Form.Item>
{isEdit && (
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
)}
</Form>
</Drawer>
);
}

View File

@ -0,0 +1,44 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { fetchCurrentUser, login as apiLogin, logout as apiLogout } from '../api/auth';
import type { CurrentUser } from '../api/auth';
interface AuthContextValue {
user: CurrentUser | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCurrentUser()
.then(setUser)
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (email: string, password: string) => {
const u = await apiLogin(email, password);
setUser(u);
}, []);
const logout = useCallback(async () => {
await apiLogout();
setUser(null);
}, []);
const value = useMemo(() => ({ user, loading, login, logout }), [user, loading, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
return ctx;
}

View File

@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchBranding, type Branding } from '../api/branding';
const FALLBACK: Branding = {
applicationName: 'Power Monitoring Portal',
logoUrl: '',
primaryColor: '#1f2937',
secondaryColor: '#374151',
accentColor: '#2563eb',
footerText: '',
};
interface BrandingContextValue {
branding: Branding;
loading: boolean;
}
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
export function BrandingProvider({ children }: { children: ReactNode }) {
const { data, isLoading } = useQuery({
queryKey: ['branding'],
queryFn: fetchBranding,
staleTime: 60_000,
});
const branding = useMemo<Branding>(() => data ?? FALLBACK, [data]);
useEffect(() => {
document.title = branding.applicationName;
const root = document.documentElement;
root.style.setProperty('--brand-primary', branding.primaryColor);
root.style.setProperty('--brand-secondary', branding.secondaryColor);
root.style.setProperty('--brand-accent', branding.accentColor);
}, [branding]);
const value = useMemo(() => ({ branding, loading: isLoading }), [branding, isLoading]);
return <BrandingContext.Provider value={value}>{children}</BrandingContext.Provider>;
}
export function useBranding(): BrandingContextValue {
const ctx = useContext(BrandingContext);
if (!ctx) throw new Error('useBranding must be used inside BrandingProvider');
return ctx;
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import 'antd/dist/reset.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@ -0,0 +1,217 @@
import { useState } from 'react';
import { Card, Table, Button, Space, Tag, Popconfirm, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listSites, createSite, updateSite, deleteSite,
listSiteDevices, createDevice, updateDevice, deleteDevice,
type Site, type Device, type UpsertSite, type UpsertDevice,
} from '../api/sites';
import { SiteFormModal } from '../components/sites/SiteFormModal';
import { DeviceFormModal } from '../components/sites/DeviceFormModal';
const { Text } = Typography;
export function AdminSitesPage() {
const qc = useQueryClient();
const [siteModal, setSiteModal] = useState<{ open: boolean; editing: Site | null }>({ open: false, editing: null });
const [deviceModal, setDeviceModal] = useState<{ open: boolean; siteId: string | null; editing: Device | null }>({
open: false, siteId: null, editing: null,
});
const [expanded, setExpanded] = useState<string[]>([]);
const { data: sites = [], isLoading } = useQuery({ queryKey: ['sites'], queryFn: listSites });
const createSiteMut = useMutation({
mutationFn: (p: UpsertSite) => createSite(p),
onSuccess: () => { message.success('Site created'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); },
onError: () => message.error('Create failed'),
});
const updateSiteMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpsertSite }) => updateSite(id, payload),
onSuccess: () => { message.success('Site updated'); setSiteModal({ open: false, editing: null }); qc.invalidateQueries({ queryKey: ['sites'] }); },
onError: () => message.error('Update failed'),
});
const deleteSiteMut = useMutation({
mutationFn: (id: string) => deleteSite(id),
onSuccess: () => { message.success('Site deleted'); qc.invalidateQueries({ queryKey: ['sites'] }); },
onError: () => message.error('Delete failed'),
});
const createDeviceMut = useMutation({
mutationFn: ({ siteId, payload }: { siteId: string; payload: UpsertDevice }) => createDevice(siteId, payload),
onSuccess: (_, vars) => {
message.success('Device created');
setDeviceModal({ open: false, siteId: null, editing: null });
qc.invalidateQueries({ queryKey: ['devices', vars.siteId] });
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Create failed'),
});
const updateDeviceMut = useMutation({
mutationFn: ({ deviceId, payload }: { deviceId: string; payload: UpsertDevice }) => updateDevice(deviceId, payload),
onSuccess: () => {
message.success('Device updated');
setDeviceModal({ open: false, siteId: null, editing: null });
qc.invalidateQueries({ queryKey: ['devices'] });
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Update failed'),
});
const deleteDeviceMut = useMutation({
mutationFn: ({ deviceId }: { deviceId: string; siteId: string }) => deleteDevice(deviceId),
onSuccess: (_, vars) => {
message.success('Device deleted');
qc.invalidateQueries({ queryKey: ['devices', vars.siteId] });
qc.invalidateQueries({ queryKey: ['sites'] });
},
onError: () => message.error('Delete failed'),
});
const onSiteSubmit = (payload: UpsertSite) => {
if (siteModal.editing) updateSiteMut.mutate({ id: siteModal.editing.id, payload });
else createSiteMut.mutate(payload);
};
const onDeviceSubmit = (payload: UpsertDevice) => {
if (deviceModal.editing) {
updateDeviceMut.mutate({ deviceId: deviceModal.editing.id, payload });
} else if (deviceModal.siteId) {
createDeviceMut.mutate({ siteId: deviceModal.siteId, payload });
}
};
const columns: ColumnsType<Site> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'Address', dataIndex: 'address', key: 'address', render: (v) => v ?? <Text type="secondary"></Text> },
{ title: 'Devices', dataIndex: 'deviceCount', key: 'deviceCount' },
{
title: 'Active',
dataIndex: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>),
},
{
title: 'Actions',
key: 'actions',
render: (_, site) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => setSiteModal({ open: true, editing: site })}>Edit</Button>
<Popconfirm
title={`Delete site "${site.name}"? All its devices and measurements will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteSiteMut.mutate(site.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card
title="Sites"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setSiteModal({ open: true, editing: null })}>
New site
</Button>
}
>
<Table<Site>
rowKey="id"
columns={columns}
dataSource={sites}
loading={isLoading}
pagination={false}
expandable={{
expandedRowKeys: expanded,
onExpandedRowsChange: (keys) => setExpanded(keys as string[]),
expandedRowRender: (site) => (
<DeviceSubTable
site={site}
onCreate={() => setDeviceModal({ open: true, siteId: site.id, editing: null })}
onEdit={(d) => setDeviceModal({ open: true, siteId: site.id, editing: d })}
onDelete={(d) => deleteDeviceMut.mutate({ deviceId: d.id, siteId: site.id })}
/>
),
}}
/>
<SiteFormModal
open={siteModal.open}
editing={siteModal.editing}
submitting={createSiteMut.isPending || updateSiteMut.isPending}
onClose={() => setSiteModal({ open: false, editing: null })}
onSubmit={onSiteSubmit}
/>
<DeviceFormModal
open={deviceModal.open}
editing={deviceModal.editing}
submitting={createDeviceMut.isPending || updateDeviceMut.isPending}
onClose={() => setDeviceModal({ open: false, siteId: null, editing: null })}
onSubmit={onDeviceSubmit}
/>
</Card>
);
}
function DeviceSubTable({
site, onCreate, onEdit, onDelete,
}: {
site: Site;
onCreate: () => void;
onEdit: (d: Device) => void;
onDelete: (d: Device) => void;
}) {
const { data: devices = [], isLoading } = useQuery({
queryKey: ['devices', site.id],
queryFn: () => listSiteDevices(site.id),
});
const columns: ColumnsType<Device> = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'External ID', dataIndex: 'externalId', key: 'externalId' },
{ title: 'Description', dataIndex: 'description', key: 'desc' },
{
title: 'Active',
dataIndex: 'isActive',
render: (v: boolean) => (v ? <Tag color="green">Active</Tag> : <Tag>Inactive</Tag>),
},
{
title: 'Actions',
key: 'actions',
render: (_, d) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => onEdit(d)}>Edit</Button>
<Popconfirm
title={`Delete device "${d.name}"? Its measurements will be removed.`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => onDelete(d)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card size="small" style={{ background: '#fafafa' }}>
<Space style={{ marginBottom: 8, justifyContent: 'flex-end', width: '100%' }}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onCreate}>New device</Button>
</Space>
<Table<Device>
rowKey="id"
size="small"
columns={columns}
dataSource={devices}
loading={isLoading}
pagination={false}
/>
</Card>
);
}

View File

@ -0,0 +1,15 @@
import { Card, Typography } from 'antd';
const { Title, Paragraph } = Typography;
export function DashboardPage() {
return (
<Card>
<Title level={3} style={{ marginTop: 0 }}>Dashboard</Title>
<Paragraph>
This is the authenticated landing page. Real telemetry, cost summaries, and embedded
Grafana dashboards land in later phases.
</Paragraph>
</Card>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect, useMemo, useState } from 'react';
import { Card, Empty, Layout, Menu, Spin, Typography } from 'antd';
import { LineChartOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { fetchGrafanaConfig, type GrafanaDashboard } from '../api/grafana';
const { Sider, Content } = Layout;
const { Text } = Typography;
export function DashboardsPage() {
const { data, isLoading } = useQuery({
queryKey: ['grafana-config'],
queryFn: fetchGrafanaConfig,
staleTime: 60_000,
});
const dashboards = data?.dashboards ?? [];
const baseUrl = data?.baseUrl?.replace(/\/$/, '') ?? '';
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
if (selected) return;
const initial = data?.defaultDashboardUid && dashboards.some((d) => d.uid === data.defaultDashboardUid)
? data.defaultDashboardUid
: dashboards[0]?.uid ?? null;
if (initial) setSelected(initial);
}, [data, dashboards, selected]);
const iframeSrc = useMemo(() => {
if (!baseUrl || !selected) return null;
return `${baseUrl}/d/${encodeURIComponent(selected)}?orgId=1&kiosk=tv&theme=light`;
}, [baseUrl, selected]);
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spin size="large" />
</div>
);
}
if (dashboards.length === 0) {
return (
<Card>
<Empty description="No dashboards configured. Add entries under Grafana.Dashboards in configuration." />
</Card>
);
}
return (
<Layout style={{ background: 'transparent', minHeight: 'calc(100vh - 200px)' }}>
<Sider width={260} style={{ background: '#fff', marginRight: 16, borderRadius: 4 }}>
<Menu
mode="inline"
selectedKeys={selected ? [selected] : []}
onClick={(e) => setSelected(e.key)}
items={dashboards.map((d: GrafanaDashboard) => ({
key: d.uid,
icon: <LineChartOutlined />,
label: (
<div>
<div>{d.title}</div>
{d.description && (
<Text type="secondary" style={{ fontSize: 11 }}>{d.description}</Text>
)}
</div>
),
}))}
/>
</Sider>
<Content>
<Card bodyStyle={{ padding: 0 }}>
{iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Grafana dashboard"
style={{ width: '100%', height: 'calc(100vh - 220px)', border: 0, display: 'block' }}
/>
) : (
<Empty description="Select a dashboard from the sidebar" />
)}
</Card>
</Content>
</Layout>
);
}

View File

@ -0,0 +1,87 @@
import { useState } from 'react';
import { Form, Input, Button, Card, Typography, Alert, Space } from 'antd';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useBranding } from '../hooks/useBranding';
const { Title, Text } = Typography;
interface LoginValues {
email: string;
password: string;
}
export function LoginPage() {
const { login } = useAuth();
const { branding } = useBranding();
const navigate = useNavigate();
const location = useLocation();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const from = (location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';
const onFinish = async (values: LoginValues) => {
setError(null);
setSubmitting(true);
try {
await login(values.email, values.password);
navigate(from, { replace: true });
} catch {
setError('Invalid email or password.');
} finally {
setSubmitting(false);
}
};
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: '#f5f5f5',
}}
>
<Card style={{ width: 380 }}>
<Space direction="vertical" align="center" style={{ width: '100%', marginBottom: 24 }}>
{branding.logoUrl && (
<img src={branding.logoUrl} alt="" style={{ maxHeight: 56, maxWidth: 240 }} />
)}
<Title level={3} style={{ margin: 0, textAlign: 'center' }}>
{branding.applicationName}
</Title>
<Text type="secondary">Sign in to continue</Text>
</Space>
{error && <Alert type="error" message={error} style={{ marginBottom: 16 }} />}
<Form layout="vertical" onFinish={onFinish} requiredMark={false}>
<Form.Item
name="email"
label="Email"
rules={[{ required: true, message: 'Email is required' }]}
>
<Input autoComplete="username" />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[{ required: true, message: 'Password is required' }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" block loading={submitting}>
Sign in
</Button>
</Form.Item>
</Form>
{branding.footerText && (
<Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 16, fontSize: 12 }}>
{branding.footerText}
</Text>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,23 @@
import { Card, Tabs } from 'antd';
import { BrandingForm } from '../components/settings/BrandingForm';
import { MunicipalityList } from '../components/settings/rates/MunicipalityList';
import { GrafanaInfoCard } from '../components/settings/GrafanaInfoCard';
import { ConfigOverviewCard } from '../components/settings/ConfigOverviewCard';
import { UsersPage } from './UsersPage';
export function SettingsPage() {
return (
<Card>
<Tabs
defaultActiveKey="branding"
items={[
{ key: 'branding', label: 'Branding', children: <BrandingForm /> },
{ key: 'rates', label: 'Rates', children: <MunicipalityList /> },
{ key: 'users', label: 'Users', children: <UsersPage /> },
{ key: 'grafana', label: 'Grafana', children: <GrafanaInfoCard /> },
{ key: 'app-config', label: 'App config', children: <ConfigOverviewCard /> },
]}
/>
</Card>
);
}

View File

@ -0,0 +1,198 @@
import { useState } from 'react';
import { Card, Button, Table, Tag, Popconfirm, Modal, Input, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
listUsers,
createUser,
updateUser,
deleteUser,
resetUserPassword,
type UserListItem,
type CreateUserPayload,
type UpdateUserPayload,
} from '../api/users';
import { UserFormDrawer } from '../components/users/UserFormDrawer';
type DrawerMode = { kind: 'create' } | { kind: 'edit'; user: UserListItem };
export function UsersPage() {
const qc = useQueryClient();
const [drawerMode, setDrawerMode] = useState<DrawerMode | null>(null);
const [drawerError, setDrawerError] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<UserListItem | null>(null);
const [resetValue, setResetValue] = useState('');
const { data: users = [], isLoading } = useQuery({
queryKey: ['admin', 'users'],
queryFn: listUsers,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['admin', 'users'] });
const createMut = useMutation({
mutationFn: (payload: CreateUserPayload) => createUser(payload),
onSuccess: () => {
message.success('User created');
setDrawerMode(null);
setDrawerError(null);
invalidate();
},
onError: (err: unknown) => setDrawerError(errorMessage(err)),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateUserPayload }) => updateUser(id, payload),
onSuccess: () => {
message.success('User updated');
setDrawerMode(null);
setDrawerError(null);
invalidate();
},
onError: (err: unknown) => setDrawerError(errorMessage(err)),
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
message.success('User deleted');
invalidate();
},
onError: (err: unknown) => message.error(errorMessage(err)),
});
const resetMut = useMutation({
mutationFn: ({ id, newPassword }: { id: string; newPassword: string }) =>
resetUserPassword(id, newPassword),
onSuccess: () => {
message.success('Password reset');
setResetTarget(null);
setResetValue('');
},
onError: (err: unknown) => message.error(errorMessage(err)),
});
const handleSubmit = async (values: CreateUserPayload | UpdateUserPayload) => {
setDrawerError(null);
if (drawerMode?.kind === 'edit') {
await updateMut.mutateAsync({ id: drawerMode.user.id, payload: values as UpdateUserPayload });
} else {
await createMut.mutateAsync(values as CreateUserPayload);
}
};
const columns: ColumnsType<UserListItem> = [
{ title: 'Email', dataIndex: 'email', key: 'email' },
{ title: 'Name', dataIndex: 'displayName', key: 'displayName' },
{
title: 'Roles',
dataIndex: 'roles',
key: 'roles',
render: (roles: string[]) =>
roles.length === 0
? <Tag>User</Tag>
: roles.map((r) => <Tag color={r === 'Admin' ? 'blue' : undefined} key={r}>{r}</Tag>),
},
{
title: 'Active',
dataIndex: 'isActive',
key: 'isActive',
render: (active: boolean) => active ? <Tag color="green">Active</Tag> : <Tag color="red">Disabled</Tag>,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: 'Actions',
key: 'actions',
render: (_, user) => (
<Space>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'edit', user }); }}
>
Edit
</Button>
<Button
size="small"
icon={<KeyOutlined />}
onClick={() => { setResetValue(''); setResetTarget(user); }}
>
Reset password
</Button>
<Popconfirm
title={`Delete ${user.email}?`}
okText="Delete"
okButtonProps={{ danger: true }}
onConfirm={() => deleteMut.mutate(user.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>Delete</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Card
title="Users"
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setDrawerError(null); setDrawerMode({ kind: 'create' }); }}
>
New user
</Button>
}
>
<Table<UserListItem>
rowKey="id"
columns={columns}
dataSource={users}
loading={isLoading}
pagination={{ pageSize: 20 }}
/>
<UserFormDrawer
open={drawerMode !== null}
mode={drawerMode}
submitting={createMut.isPending || updateMut.isPending}
error={drawerError}
onClose={() => { setDrawerMode(null); setDrawerError(null); }}
onSubmit={handleSubmit}
/>
<Modal
title={resetTarget ? `Reset password for ${resetTarget.email}` : 'Reset password'}
open={resetTarget !== null}
confirmLoading={resetMut.isPending}
onCancel={() => { setResetTarget(null); setResetValue(''); }}
onOk={() => resetTarget && resetMut.mutate({ id: resetTarget.id, newPassword: resetValue })}
okText="Reset"
okButtonProps={{ disabled: resetValue.length < 8 }}
>
<Input.Password
placeholder="New password (min 8 chars, upper + lower + digit)"
value={resetValue}
onChange={(e) => setResetValue(e.target.value)}
autoComplete="new-password"
/>
</Modal>
</Card>
);
}
function errorMessage(err: unknown): string {
if (typeof err === 'object' && err !== null && 'response' in err) {
const data = (err as { response?: { data?: { error?: string; errors?: string[] } } }).response?.data;
if (data?.error) return data.error;
if (data?.errors?.length) return data.errors.join('; ');
}
return 'Request failed.';
}

1
portal/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,23 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: false,
},
'/health': {
target: 'http://localhost:8080',
changeOrigin: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});

View File

View File

@ -0,0 +1,150 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"id": 1,
"type": "timeseries",
"title": "Active power (kW)",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 9 },
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "multi", "sort": "none" },
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"format": "time_series",
"rawSql": "SELECT time_bucket($__interval, \"Time\") AS time, avg(\"ActivePowerKw\") AS \"kW\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' AND $__timeFilter(\"Time\") GROUP BY 1 ORDER BY 1"
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Cumulative energy imported (kWh)",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"gridPos": { "x": 0, "y": 9, "w": 12, "h": 9 },
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "line",
"lineWidth": 2,
"fillOpacity": 5,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"tooltip": { "mode": "single", "sort": "none" },
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true }
},
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"format": "time_series",
"rawSql": "SELECT \"Time\" AS time, \"EnergyImportedKwh\" AS \"kWh imported\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' AND $__timeFilter(\"Time\") AND \"EnergyImportedKwh\" IS NOT NULL ORDER BY \"Time\""
}
]
},
{
"id": 3,
"type": "stat",
"title": "Latest active power",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"gridPos": { "x": 12, "y": 9, "w": 12, "h": 9 },
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "orange", "value": 50 },
{ "color": "red", "value": 100 }
]
}
},
"overrides": []
},
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"targets": [
{
"refId": "A",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"format": "time_series",
"rawSql": "SELECT \"Time\" AS time, \"ActivePowerKw\" AS \"kW\" FROM monitoring.\"PowerMeasurements\" WHERE \"DeviceId\" = '${device}' ORDER BY \"Time\" DESC LIMIT 1"
}
]
}
],
"refresh": "30s",
"schemaVersion": 38,
"tags": ["power"],
"templating": {
"list": [
{
"name": "device",
"label": "Device",
"type": "query",
"datasource": { "type": "postgres", "uid": "timescaledb" },
"query": "SELECT \"Name\" AS __text, \"Id\"::text AS __value FROM monitoring.\"Devices\" WHERE \"IsActive\" = true ORDER BY \"Name\"",
"refresh": 1,
"multi": false,
"includeAll": false,
"current": {}
}
]
},
"time": { "from": "now-24h", "to": "now" },
"timepicker": {},
"timezone": "",
"title": "Power Overview",
"uid": "power-overview",
"version": 1,
"weekStart": ""
}

View File

@ -0,0 +1,13 @@
apiVersion: 1
providers:
- name: 'Tau Acuvim Portal'
orgId: 1
folder: ''
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,18 @@
apiVersion: 1
datasources:
- name: TimescaleDB
uid: timescaledb
type: postgres
access: proxy
url: timescaledb:5432
user: $POSTGRES_USER
secureJsonData:
password: $POSTGRES_PASSWORD
jsonData:
database: $POSTGRES_DB
sslmode: disable
postgresVersion: 1600
timescaledb: true
isDefault: true
editable: false

View File

@ -0,0 +1,42 @@
using Microsoft.Extensions.Hosting;
namespace Tau.Acuvim.Portal.Configuration;
public static class ConnectionStringResolver
{
public sealed record Resolution(string ConnectionString, string Source);
// Precedence:
// 1. Database.ConnectionString (explicit) — always wins.
// 2. Database.AutoProvisionLocalTimescaleDb == true AND env != Production — build from TimescaleDb block.
// 3. Otherwise — throw. Production must supply an explicit connection string.
public static Resolution Resolve(
DatabaseOptions database,
TimescaleDbOptions timescale,
IHostEnvironment env)
{
if (!string.IsNullOrWhiteSpace(database.ConnectionString))
{
return new Resolution(database.ConnectionString, "Database:ConnectionString");
}
if (database.AutoProvisionLocalTimescaleDb && !env.IsProduction())
{
var built =
$"Host={timescale.Host};Port={timescale.Port};Database={timescale.Database};" +
$"Username={timescale.Username};Password={timescale.Password}";
return new Resolution(built, "auto-provision from TimescaleDb section");
}
if (database.AutoProvisionLocalTimescaleDb && env.IsProduction())
{
throw new InvalidOperationException(
"AutoProvisionLocalTimescaleDb=true is not permitted in Production. " +
"Set Database:ConnectionString explicitly via env var or secret.");
}
throw new InvalidOperationException(
"No database connection string available. Set Database:ConnectionString, " +
"or enable Database:AutoProvisionLocalTimescaleDb in a non-Production environment.");
}
}

View File

@ -0,0 +1,10 @@
namespace Tau.Acuvim.Portal.Configuration;
public sealed class MonitoringOptions
{
public const string SectionName = "Monitoring";
public string ChunkTimeInterval { get; set; } = "7 days";
public bool EnableHourlyAggregates { get; set; }
}

View File

@ -0,0 +1,74 @@
namespace Tau.Acuvim.Portal.Configuration;
public sealed class ApplicationOptions
{
public const string SectionName = "Application";
public string Name { get; set; } = "Power Monitoring Portal";
public string Environment { get; set; } = "Development";
public string PublicUrl { get; set; } = "http://localhost:8080";
}
public sealed class DatabaseOptions
{
public const string SectionName = "Database";
public string Provider { get; set; } = "PostgreSQL";
public string ConnectionString { get; set; } = string.Empty;
public bool AutoProvisionLocalTimescaleDb { get; set; } = true;
public bool MigrateOnStartup { get; set; } = true;
}
public sealed class TimescaleDbOptions
{
public const string SectionName = "TimescaleDb";
public string Host { get; set; } = "timescaledb";
public int Port { get; set; } = 5432;
public string Database { get; set; } = "power_monitoring";
public string Username { get; set; } = "power_user";
public string Password { get; set; } = string.Empty;
}
public sealed class GrafanaOptions
{
public const string SectionName = "Grafana";
public string BaseUrl { get; set; } = "http://localhost:3001";
public string InternalUrl { get; set; } = "http://grafana:3000";
public string EmbedPathPrefix { get; set; } = "/grafana";
public string EmbedMode { get; set; } = "iframe";
public string DefaultDashboardUid { get; set; } = string.Empty;
public string AuthMode { get; set; } = "anonymous-local-only";
public List<GrafanaDashboardOption> Dashboards { get; set; } = new();
}
public sealed class GrafanaDashboardOption
{
public string Uid { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}
public sealed class WhiteLabelOptions
{
public const string SectionName = "WhiteLabel";
public string ApplicationName { get; set; } = "Power Monitoring Portal";
public string LogoUrl { get; set; } = string.Empty;
public string LogoStoragePath { get; set; } = "/data/branding";
public string PrimaryColor { get; set; } = "#1f2937";
public string SecondaryColor { get; set; } = "#374151";
public string AccentColor { get; set; } = "#2563eb";
public string FooterText { get; set; } = "Powered by Power Monitoring Portal";
}
public sealed class AuthenticationOptions
{
public const string SectionName = "Authentication";
public string CookieName { get; set; } = "TauPortal.Auth";
public bool RequireConfirmedEmail { get; set; }
public string DefaultAdminEmail { get; set; } = "admin@example.com";
public string DefaultAdminPassword { get; set; } = "ChangeMe123!";
}

View File

@ -0,0 +1,11 @@
namespace Tau.Acuvim.Portal.Constants;
public static class Roles
{
public const string Admin = "Admin";
}
public static class Policies
{
public const string AdminOnly = "AdminOnly";
}

View File

@ -0,0 +1,5 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record LoginRequest(string Email, string Password);
public sealed record CurrentUserResponse(string Email, string DisplayName, IReadOnlyList<string> Roles);

View File

@ -0,0 +1,19 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record BrandingDto(
string ApplicationName,
string LogoUrl,
string PrimaryColor,
string SecondaryColor,
string AccentColor,
string FooterText);
public sealed record UpdateBrandingRequest(
string ApplicationName,
string PrimaryColor,
string SecondaryColor,
string AccentColor,
string FooterText,
string? LogoUrl);
public sealed record LogoUploadResponse(string LogoUrl);

View File

@ -0,0 +1,35 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record AppInfoDto(string Name, string Environment, string PublicUrl);
public sealed record DatabaseInfoDto(
string Provider,
string Host,
int Port,
string Database,
bool MigrateOnStartup,
bool AutoProvisionLocalTimescaleDb,
string ResolvedVia);
public sealed record GrafanaInfoDto(
string BaseUrl,
string InternalUrl,
string EmbedPathPrefix,
string EmbedMode,
string DefaultDashboardUid,
string AuthMode,
int DashboardCount);
public sealed record MonitoringInfoDto(string ChunkTimeInterval, bool EnableHourlyAggregates);
public sealed record AuthInfoDto(string CookieName, bool RequireConfirmedEmail, string DefaultAdminEmail);
public sealed record BuildInfoDto(string AssemblyVersion, string Framework, DateTime StartedAtUtc);
public sealed record ConfigOverviewDto(
AppInfoDto Application,
DatabaseInfoDto Database,
GrafanaInfoDto Grafana,
MonitoringInfoDto Monitoring,
AuthInfoDto Authentication,
BuildInfoDto Build);

View File

@ -0,0 +1,8 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record GrafanaDashboardDto(string Uid, string Title, string Description);
public sealed record GrafanaConfigDto(
string BaseUrl,
string DefaultDashboardUid,
IReadOnlyList<GrafanaDashboardDto> Dashboards);

View File

@ -0,0 +1,45 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record SiteDto(
Guid Id,
string Name,
string? Address,
int? MunicipalityId,
bool IsActive,
int DeviceCount);
public sealed record UpsertSiteRequest(string Name, string? Address, int? MunicipalityId, bool IsActive);
public sealed record DeviceDto(
Guid Id,
Guid SiteId,
string Name,
string ExternalId,
string? Description,
bool IsActive);
public sealed record UpsertDeviceRequest(string Name, string ExternalId, string? Description, bool IsActive);
public sealed record MeasurementIngestRow(
DateTime Time,
string DeviceExternalId,
double ActivePowerKw,
double? ReactivePowerKvar,
double? ApparentPowerKva,
double? PowerFactor,
double? VoltageV,
double? FrequencyHz,
double? EnergyImportedKwh,
double? EnergyExportedKwh,
string? Source);
public sealed record MeasurementBucketDto(
DateTime BucketStart,
double AvgActivePowerKw,
double MaxActivePowerKw,
double MinActivePowerKw,
double? EnergyImportedKwhDelta,
double? EnergyExportedKwhDelta,
int SampleCount);
public sealed record IngestResultDto(int Accepted, int Rejected);

View File

@ -0,0 +1,51 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record MunicipalityDto(
int Id,
string Name,
string? TimeZoneId,
bool IsActive,
int TariffCount);
public sealed record CreateMunicipalityRequest(string Name, string? TimeZoneId, bool IsActive);
public sealed record UpdateMunicipalityRequest(string Name, string? TimeZoneId, bool IsActive);
public sealed record TariffSummaryDto(
int Id,
string Name,
DateOnly EffectiveFrom,
DateOnly? EffectiveTo,
decimal DefaultRatePerKwh,
decimal FixedMonthlyCharge,
decimal VatPercentage,
bool IsActive,
int PeriodCount);
public sealed record TariffPeriodDto(
string Name,
int DaysOfWeek,
string StartTime,
string EndTime,
decimal RatePerKwh);
public sealed record TariffDetailDto(
int Id,
int MunicipalityId,
string Name,
DateOnly EffectiveFrom,
DateOnly? EffectiveTo,
decimal DefaultRatePerKwh,
decimal FixedMonthlyCharge,
decimal VatPercentage,
bool IsActive,
IReadOnlyList<TariffPeriodDto> Periods);
public sealed record UpsertTariffRequest(
string Name,
DateOnly EffectiveFrom,
DateOnly? EffectiveTo,
decimal DefaultRatePerKwh,
decimal FixedMonthlyCharge,
decimal VatPercentage,
bool IsActive,
IReadOnlyList<TariffPeriodDto> Periods);

View File

@ -0,0 +1,22 @@
namespace Tau.Acuvim.Portal.DTOs;
public sealed record UserListItemDto(
string Id,
string Email,
string DisplayName,
bool IsActive,
DateTime CreatedAt,
IReadOnlyList<string> Roles);
public sealed record CreateUserRequest(
string Email,
string DisplayName,
string Password,
bool IsAdmin);
public sealed record UpdateUserRequest(
string DisplayName,
bool IsActive,
bool IsAdmin);
public sealed record ResetPasswordRequest(string NewPassword);

View File

@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Domain.Branding;
using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.Domain.Monitoring;
using Tau.Acuvim.Portal.Domain.Rates;
namespace Tau.Acuvim.Portal.Data;
public class AppDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<WhiteLabelSettings> WhiteLabelSettings => Set<WhiteLabelSettings>();
public DbSet<Municipality> Municipalities => Set<Municipality>();
public DbSet<Tariff> Tariffs => Set<Tariff>();
public DbSet<TariffPeriod> TariffPeriods => Set<TariffPeriod>();
public DbSet<Site> Sites => Set<Site>();
public DbSet<Device> Devices => Set<Device>();
public DbSet<PowerMeasurement> PowerMeasurements => Set<PowerMeasurement>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.HasDefaultSchema("app");
builder.Entity<ApplicationUser>().ToTable("AspNetUsers", schema: "identity");
builder.Entity<IdentityRole>().ToTable("AspNetRoles", schema: "identity");
builder.Entity<IdentityUserRole<string>>().ToTable("AspNetUserRoles", schema: "identity");
builder.Entity<IdentityUserClaim<string>>().ToTable("AspNetUserClaims", schema: "identity");
builder.Entity<IdentityRoleClaim<string>>().ToTable("AspNetRoleClaims", schema: "identity");
builder.Entity<IdentityUserLogin<string>>().ToTable("AspNetUserLogins", schema: "identity");
builder.Entity<IdentityUserToken<string>>().ToTable("AspNetUserTokens", schema: "identity");
builder.Entity<WhiteLabelSettings>(entity =>
{
entity.ToTable("WhiteLabelSettings", schema: "app");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).ValueGeneratedNever();
});
builder.Entity<Municipality>(entity =>
{
entity.ToTable("Municipalities", schema: "app");
entity.HasIndex(x => x.Name).IsUnique();
});
builder.Entity<Tariff>(entity =>
{
entity.ToTable("Tariffs", schema: "app");
entity.Property(x => x.DefaultRatePerKwh).HasPrecision(10, 4);
entity.Property(x => x.FixedMonthlyCharge).HasPrecision(10, 2);
entity.Property(x => x.VatPercentage).HasPrecision(5, 2);
entity.HasIndex(x => new { x.MunicipalityId, x.EffectiveFrom });
entity.HasOne(x => x.Municipality)
.WithMany(m => m.Tariffs)
.HasForeignKey(x => x.MunicipalityId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<TariffPeriod>(entity =>
{
entity.ToTable("TariffPeriods", schema: "app");
entity.Property(x => x.RatePerKwh).HasPrecision(10, 4);
entity.HasOne(x => x.Tariff)
.WithMany(t => t.Periods)
.HasForeignKey(x => x.TariffId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<Site>(entity =>
{
entity.ToTable("Sites", schema: "monitoring");
entity.HasIndex(x => x.Name);
entity.HasOne(x => x.Municipality)
.WithMany()
.HasForeignKey(x => x.MunicipalityId)
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Device>(entity =>
{
entity.ToTable("Devices", schema: "monitoring");
entity.HasIndex(x => x.ExternalId);
entity.HasIndex(x => new { x.SiteId, x.ExternalId }).IsUnique();
entity.HasOne(x => x.Site)
.WithMany(s => s.Devices)
.HasForeignKey(x => x.SiteId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<PowerMeasurement>(entity =>
{
entity.ToTable("PowerMeasurements", schema: "monitoring");
entity.HasKey(x => new { x.Time, x.DeviceId });
entity.HasIndex(x => new { x.DeviceId, x.Time }).IsDescending(false, true);
entity.HasOne(x => x.Device)
.WithMany()
.HasForeignKey(x => x.DeviceId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Branding;
public class WhiteLabelSettings
{
public const int SingletonId = 1;
public int Id { get; set; } = SingletonId;
[MaxLength(200)]
public string ApplicationName { get; set; } = string.Empty;
[MaxLength(500)]
public string LogoUrl { get; set; } = string.Empty;
[MaxLength(20)]
public string PrimaryColor { get; set; } = "#1f2937";
[MaxLength(20)]
public string SecondaryColor { get; set; } = "#374151";
[MaxLength(20)]
public string AccentColor { get; set; } = "#2563eb";
[MaxLength(500)]
public string FooterText { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace Tau.Acuvim.Portal.Domain.Identity;
public class ApplicationUser : IdentityUser
{
[MaxLength(200)]
public string DisplayName { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Monitoring;
public class Device
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SiteId { get; set; }
public Site? Site { get; set; }
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(200)]
public string ExternalId { get; set; } = string.Empty;
[MaxLength(500)]
public string? Description { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Monitoring;
public class PowerMeasurement
{
public DateTime Time { get; set; }
public Guid DeviceId { get; set; }
public Device? Device { get; set; }
public double ActivePowerKw { get; set; }
public double? ReactivePowerKvar { get; set; }
public double? ApparentPowerKva { get; set; }
public double? PowerFactor { get; set; }
public double? VoltageV { get; set; }
public double? FrequencyHz { get; set; }
public double? EnergyImportedKwh { get; set; }
public double? EnergyExportedKwh { get; set; }
[MaxLength(50)]
public string? Source { get; set; }
}

View File

@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using Tau.Acuvim.Portal.Domain.Rates;
namespace Tau.Acuvim.Portal.Domain.Monitoring;
public class Site
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string? Address { get; set; }
public int? MunicipalityId { get; set; }
public Municipality? Municipality { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<Device> Devices { get; set; } = new();
}

View File

@ -0,0 +1,3 @@
namespace Tau.Acuvim.Portal.Domain.Rates;
public sealed record ConsumptionSample(DateTime TimestampUtc, decimal Kwh);

View File

@ -0,0 +1,8 @@
namespace Tau.Acuvim.Portal.Domain.Rates;
public sealed record CostBreakdown(
decimal BaseCost,
decimal FixedCharges,
decimal Subtotal,
decimal Vat,
decimal TotalCost);

View File

@ -0,0 +1,32 @@
namespace Tau.Acuvim.Portal.Domain.Rates;
[Flags]
public enum DayOfWeekFlag
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday,
Weekends = Saturday | Sunday,
All = Weekdays | Weekends
}
public static class DayOfWeekFlagExtensions
{
public static DayOfWeekFlag ToFlag(this DayOfWeek day) => day switch
{
DayOfWeek.Monday => DayOfWeekFlag.Monday,
DayOfWeek.Tuesday => DayOfWeekFlag.Tuesday,
DayOfWeek.Wednesday => DayOfWeekFlag.Wednesday,
DayOfWeek.Thursday => DayOfWeekFlag.Thursday,
DayOfWeek.Friday => DayOfWeekFlag.Friday,
DayOfWeek.Saturday => DayOfWeekFlag.Saturday,
DayOfWeek.Sunday => DayOfWeekFlag.Sunday,
_ => DayOfWeekFlag.None
};
}

View File

@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Rates;
public class Municipality
{
public int Id { get; set; }
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(100)]
public string? TimeZoneId { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<Tariff> Tariffs { get; set; } = new();
}

View File

@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Rates;
public class Tariff
{
public int Id { get; set; }
public int MunicipalityId { get; set; }
public Municipality? Municipality { get; set; }
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
public DateOnly EffectiveFrom { get; set; }
public DateOnly? EffectiveTo { get; set; }
public decimal DefaultRatePerKwh { get; set; }
public decimal FixedMonthlyCharge { get; set; }
public decimal VatPercentage { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<TariffPeriod> Periods { get; set; } = new();
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace Tau.Acuvim.Portal.Domain.Rates;
public class TariffPeriod
{
public int Id { get; set; }
public int TariffId { get; set; }
public Tariff? Tariff { get; set; }
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
public DayOfWeekFlag DaysOfWeek { get; set; }
public TimeOnly StartTime { get; set; }
public TimeOnly EndTime { get; set; }
public decimal RatePerKwh { get; set; }
}

View File

@ -0,0 +1,16 @@
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class AdminConfigEndpoints
{
public static IEndpointRouteBuilder MapAdminConfigEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/admin/config-overview", (ConfigOverviewService svc) => Results.Ok(svc.Build()))
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Config");
return app;
}
}

View File

@ -0,0 +1,60 @@
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.DTOs;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class AdminRatesEndpoints
{
public static IEndpointRouteBuilder MapAdminRatesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/rates")
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Rates");
group.MapPost("/municipalities", async (CreateMunicipalityRequest req, RateService svc, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.Name)) return Results.BadRequest(new { error = "Name is required." });
var m = await svc.CreateMunicipalityAsync(req, ct);
return Results.Created($"/api/rates/municipalities/{m.Id}",
new MunicipalityDto(m.Id, m.Name, m.TimeZoneId, m.IsActive, 0));
});
group.MapPut("/municipalities/{id:int}", async (int id, UpdateMunicipalityRequest req, RateService svc, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.Name)) return Results.BadRequest(new { error = "Name is required." });
var ok = await svc.UpdateMunicipalityAsync(id, req, ct);
return ok ? Results.NoContent() : Results.NotFound();
});
group.MapDelete("/municipalities/{id:int}", async (int id, RateService svc, CancellationToken ct) =>
{
var ok = await svc.DeleteMunicipalityAsync(id, ct);
return ok ? Results.NoContent() : Results.NotFound();
});
group.MapPost("/municipalities/{id:int}/tariffs", async (int id, UpsertTariffRequest req, RateService svc, CancellationToken ct) =>
{
var error = TariffValidator.Validate(req);
if (error is not null) return Results.BadRequest(new { error });
var tariff = await svc.CreateTariffAsync(id, req, ct);
return Results.Created($"/api/rates/tariffs/{tariff.Id}", tariff);
});
group.MapPut("/tariffs/{id:int}", async (int id, UpsertTariffRequest req, RateService svc, CancellationToken ct) =>
{
var error = TariffValidator.Validate(req);
if (error is not null) return Results.BadRequest(new { error });
var tariff = await svc.UpdateTariffAsync(id, req, ct);
return tariff is null ? Results.NotFound() : Results.Ok(tariff);
});
group.MapDelete("/tariffs/{id:int}", async (int id, RateService svc, CancellationToken ct) =>
{
var ok = await svc.DeleteTariffAsync(id, ct);
return ok ? Results.NoContent() : Results.NotFound();
});
return app;
}
}

View File

@ -0,0 +1,121 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Endpoints;
public static class AdminUserEndpoints
{
public static IEndpointRouteBuilder MapAdminUserEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/users")
.WithTags("Admin / Users")
.RequireAuthorization(Policies.AdminOnly);
group.MapGet("/", async (UserManager<ApplicationUser> users) =>
{
var all = await users.Users.OrderBy(u => u.Email).ToListAsync();
var items = new List<UserListItemDto>(all.Count);
foreach (var u in all)
{
var roles = await users.GetRolesAsync(u);
items.Add(new UserListItemDto(u.Id, u.Email ?? string.Empty, u.DisplayName,
u.IsActive, u.CreatedAt, roles.ToArray()));
}
return Results.Ok(items);
});
group.MapPost("/", async (CreateUserRequest req, UserManager<ApplicationUser> users) =>
{
if (string.IsNullOrWhiteSpace(req.Email) || string.IsNullOrWhiteSpace(req.Password))
{
return Results.BadRequest(new { error = "Email and password are required." });
}
var user = new ApplicationUser
{
UserName = req.Email,
Email = req.Email,
EmailConfirmed = true,
DisplayName = string.IsNullOrWhiteSpace(req.DisplayName) ? req.Email : req.DisplayName,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
var create = await users.CreateAsync(user, req.Password);
if (!create.Succeeded) return IdentityProblem(create);
if (req.IsAdmin)
{
var role = await users.AddToRoleAsync(user, Roles.Admin);
if (!role.Succeeded) return IdentityProblem(role);
}
var roles = await users.GetRolesAsync(user);
return Results.Created($"/api/admin/users/{user.Id}", new UserListItemDto(
user.Id, user.Email!, user.DisplayName, user.IsActive, user.CreatedAt, roles.ToArray()));
});
group.MapPut("/{id}", async (string id, UpdateUserRequest req, UserManager<ApplicationUser> users) =>
{
var user = await users.FindByIdAsync(id);
if (user is null) return Results.NotFound();
user.DisplayName = req.DisplayName;
user.IsActive = req.IsActive;
var update = await users.UpdateAsync(user);
if (!update.Succeeded) return IdentityProblem(update);
var hasAdmin = await users.IsInRoleAsync(user, Roles.Admin);
if (req.IsAdmin && !hasAdmin)
{
var add = await users.AddToRoleAsync(user, Roles.Admin);
if (!add.Succeeded) return IdentityProblem(add);
}
else if (!req.IsAdmin && hasAdmin)
{
var remove = await users.RemoveFromRoleAsync(user, Roles.Admin);
if (!remove.Succeeded) return IdentityProblem(remove);
}
return Results.NoContent();
});
group.MapPost("/{id}/reset-password", async (string id, ResetPasswordRequest req, UserManager<ApplicationUser> users) =>
{
var user = await users.FindByIdAsync(id);
if (user is null) return Results.NotFound();
if (string.IsNullOrWhiteSpace(req.NewPassword))
{
return Results.BadRequest(new { error = "New password is required." });
}
var token = await users.GeneratePasswordResetTokenAsync(user);
var reset = await users.ResetPasswordAsync(user, token, req.NewPassword);
return reset.Succeeded ? Results.NoContent() : IdentityProblem(reset);
});
group.MapDelete("/{id}", async (string id, HttpContext ctx, UserManager<ApplicationUser> users) =>
{
var user = await users.FindByIdAsync(id);
if (user is null) return Results.NotFound();
var callerEmail = ctx.User.FindFirstValue(ClaimTypes.Email);
if (string.Equals(callerEmail, user.Email, StringComparison.OrdinalIgnoreCase))
{
return Results.BadRequest(new { error = "You cannot delete your own account." });
}
var delete = await users.DeleteAsync(user);
return delete.Succeeded ? Results.NoContent() : IdentityProblem(delete);
});
return app;
}
private static IResult IdentityProblem(IdentityResult result) =>
Results.BadRequest(new { errors = result.Errors.Select(e => e.Description).ToArray() });
}

View File

@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Identity;
using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Endpoints;
public static class AuthEndpoints
{
public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth").WithTags("Auth");
group.MapPost("/login", async (
LoginRequest req,
SignInManager<ApplicationUser> signIn,
UserManager<ApplicationUser> users) =>
{
if (string.IsNullOrWhiteSpace(req.Email) || string.IsNullOrWhiteSpace(req.Password))
{
return Results.BadRequest(new { error = "Email and password are required." });
}
var user = await users.FindByEmailAsync(req.Email);
if (user is null || !user.IsActive)
{
return Results.Unauthorized();
}
var result = await signIn.PasswordSignInAsync(user, req.Password,
isPersistent: true, lockoutOnFailure: true);
if (!result.Succeeded)
{
return Results.Unauthorized();
}
var roles = await users.GetRolesAsync(user);
return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray()));
});
group.MapPost("/logout", async (SignInManager<ApplicationUser> signIn) =>
{
await signIn.SignOutAsync();
return Results.NoContent();
});
group.MapGet("/me", async (HttpContext ctx, UserManager<ApplicationUser> users) =>
{
if (ctx.User?.Identity?.IsAuthenticated != true)
{
return Results.Unauthorized();
}
var user = await users.GetUserAsync(ctx.User);
if (user is null || !user.IsActive)
{
return Results.Unauthorized();
}
var roles = await users.GetRolesAsync(user);
return Results.Ok(new CurrentUserResponse(user.Email!, user.DisplayName, roles.ToArray()));
}).RequireAuthorization();
return app;
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.DTOs;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class BrandingEndpoints
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".svg", ".webp"
};
private const long MaxLogoBytes = 2 * 1024 * 1024;
public static IEndpointRouteBuilder MapBrandingEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/branding", async (BrandingService svc, CancellationToken ct) =>
Results.Ok(await svc.GetAsync(ct)))
.AllowAnonymous()
.WithTags("Branding");
var admin = app.MapGroup("/api/branding")
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Branding");
admin.MapPut("/", async (UpdateBrandingRequest req, BrandingService svc, CancellationToken ct) =>
Results.Ok(await svc.UpdateAsync(req, ct)));
admin.MapPost("/logo", async (
HttpRequest http,
BrandingService svc,
IOptions<WhiteLabelOptions> opts,
CancellationToken ct) =>
{
if (!http.HasFormContentType) return Results.BadRequest(new { error = "Multipart form required." });
var form = await http.ReadFormAsync(ct);
var file = form.Files["file"] ?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0) return Results.BadRequest(new { error = "No file uploaded." });
if (file.Length > MaxLogoBytes) return Results.BadRequest(new { error = "Logo must be <= 2 MB." });
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
{
return Results.BadRequest(new { error = "Allowed: png, jpg, jpeg, svg, webp." });
}
Directory.CreateDirectory(opts.Value.LogoStoragePath);
var filename = $"logo-{DateTime.UtcNow:yyyyMMddHHmmss}{ext}";
var fullPath = Path.Combine(opts.Value.LogoStoragePath, filename);
await using (var fs = File.Create(fullPath))
{
await file.CopyToAsync(fs, ct);
}
var url = $"/branding-assets/{filename}";
await svc.SetLogoUrlAsync(url, ct);
return Results.Ok(new LogoUploadResponse(url));
}).DisableAntiforgery();
return app;
}
}

View File

@ -0,0 +1,15 @@
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class GrafanaEndpoints
{
public static IEndpointRouteBuilder MapGrafanaEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/grafana/config", (GrafanaService svc) => Results.Ok(svc.GetConfig()))
.RequireAuthorization()
.WithTags("Grafana");
return app;
}
}

View File

@ -0,0 +1,45 @@
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.DTOs;
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class MeasurementsEndpoints
{
public static IEndpointRouteBuilder MapMeasurementsEndpoints(this IEndpointRouteBuilder app)
{
var read = app.MapGroup("/api/measurements")
.RequireAuthorization()
.WithTags("Measurements");
read.MapGet("/", async (
Guid deviceId, DateTime from, DateTime to, string bucket,
MeasurementQueryService svc, CancellationToken ct) =>
{
if (!svc.IsValidBucket(bucket))
{
return Results.BadRequest(new { error = "bucket must be one of: minute, 5min, 15min, hour, day." });
}
if (to <= from) return Results.BadRequest(new { error = "'to' must be after 'from'." });
var rows = await svc.GetBucketsAsync(deviceId, from, to, bucket, ct);
return Results.Ok(rows);
});
var ingest = app.MapGroup("/api/ingest")
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Ingest");
ingest.MapPost("/measurements", async (
MeasurementIngestRow[] rows, MeasurementIngestService svc, CancellationToken ct) =>
{
if (rows is null || rows.Length == 0)
{
return Results.BadRequest(new { error = "At least one row required." });
}
var result = await svc.IngestAsync(rows, ct);
return Results.Ok(result);
});
return app;
}
}

View File

@ -0,0 +1,35 @@
using Tau.Acuvim.Portal.Services;
namespace Tau.Acuvim.Portal.Endpoints;
public static class RatesEndpoints
{
public static IEndpointRouteBuilder MapRatesEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/rates")
.RequireAuthorization()
.WithTags("Rates");
group.MapGet("/municipalities", async (RateService svc, CancellationToken ct) =>
Results.Ok(await svc.ListMunicipalitiesAsync(ct)));
group.MapGet("/municipalities/{id:int}/tariffs", async (int id, RateService svc, CancellationToken ct) =>
Results.Ok(await svc.ListTariffsAsync(id, ct)));
group.MapGet("/municipalities/{id:int}/tariffs/active",
async (int id, DateTime? at, RateService svc, CancellationToken ct) =>
{
var when = at ?? DateTime.UtcNow;
var tariff = await svc.GetActiveTariffAsync(id, when, ct);
return tariff is null ? Results.NotFound() : Results.Ok(tariff);
});
group.MapGet("/tariffs/{id:int}", async (int id, RateService svc, CancellationToken ct) =>
{
var tariff = await svc.GetTariffAsync(id, ct);
return tariff is null ? Results.NotFound() : Results.Ok(tariff);
});
return app;
}
}

View File

@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Monitoring;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Endpoints;
public static class SitesEndpoints
{
public static IEndpointRouteBuilder MapSitesEndpoints(this IEndpointRouteBuilder app)
{
var read = app.MapGroup("/api/sites")
.RequireAuthorization()
.WithTags("Sites");
read.MapGet("/", async (AppDbContext db, CancellationToken ct) =>
{
var sites = await db.Sites
.OrderBy(s => s.Name)
.Select(s => new SiteDto(s.Id, s.Name, s.Address, s.MunicipalityId, s.IsActive, s.Devices.Count))
.ToListAsync(ct);
return Results.Ok(sites);
});
read.MapGet("/{id:guid}/devices", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
var devices = await db.Devices
.Where(d => d.SiteId == id)
.OrderBy(d => d.Name)
.Select(d => new DeviceDto(d.Id, d.SiteId, d.Name, d.ExternalId, d.Description, d.IsActive))
.ToListAsync(ct);
return Results.Ok(devices);
});
var admin = app.MapGroup("/api/admin/sites")
.RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Sites");
admin.MapPost("/", async (UpsertSiteRequest req, AppDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.Name)) return Results.BadRequest(new { error = "Name is required." });
var s = new Site
{
Name = req.Name.Trim(),
Address = req.Address,
MunicipalityId = req.MunicipalityId,
IsActive = req.IsActive
};
db.Sites.Add(s);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/sites/{s.Id}",
new SiteDto(s.Id, s.Name, s.Address, s.MunicipalityId, s.IsActive, 0));
});
admin.MapPut("/{id:guid}", async (Guid id, UpsertSiteRequest req, AppDbContext db, CancellationToken ct) =>
{
var s = await db.Sites.FindAsync(new object?[] { id }, ct);
if (s is null) return Results.NotFound();
s.Name = req.Name.Trim();
s.Address = req.Address;
s.MunicipalityId = req.MunicipalityId;
s.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
admin.MapDelete("/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
var s = await db.Sites.FindAsync(new object?[] { id }, ct);
if (s is null) return Results.NotFound();
db.Sites.Remove(s);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
admin.MapPost("/{siteId:guid}/devices", async (Guid siteId, UpsertDeviceRequest req, AppDbContext db, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(req.Name) || string.IsNullOrWhiteSpace(req.ExternalId))
{
return Results.BadRequest(new { error = "Name and ExternalId are required." });
}
var site = await db.Sites.FindAsync(new object?[] { siteId }, ct);
if (site is null) return Results.NotFound();
var d = new Device
{
SiteId = siteId,
Name = req.Name.Trim(),
ExternalId = req.ExternalId.Trim(),
Description = req.Description,
IsActive = req.IsActive
};
db.Devices.Add(d);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/sites/{siteId}/devices/{d.Id}",
new DeviceDto(d.Id, d.SiteId, d.Name, d.ExternalId, d.Description, d.IsActive));
});
admin.MapPut("/devices/{deviceId:guid}", async (Guid deviceId, UpsertDeviceRequest req, AppDbContext db, CancellationToken ct) =>
{
var d = await db.Devices.FindAsync(new object?[] { deviceId }, ct);
if (d is null) return Results.NotFound();
d.Name = req.Name.Trim();
d.ExternalId = req.ExternalId.Trim();
d.Description = req.Description;
d.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
admin.MapDelete("/devices/{deviceId:guid}", async (Guid deviceId, AppDbContext db, CancellationToken ct) =>
{
var d = await db.Devices.FindAsync(new object?[] { deviceId }, ct);
if (d is null) return Results.NotFound();
db.Devices.Remove(d);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
return app;
}
}

View File

@ -0,0 +1,631 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tau.Acuvim.Portal.Data;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260518064632_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("app")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Device", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("SiteId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ExternalId");
b.HasIndex("SiteId", "ExternalId")
.IsUnique();
b.ToTable("Devices", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.PowerMeasurement", b =>
{
b.Property<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("VoltageV")
.HasColumnType("double precision");
b.HasKey("Time", "DeviceId");
b.HasIndex("DeviceId", "Time")
.IsDescending(false, true);
b.ToTable("PowerMeasurements", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("MunicipalityId");
b.HasIndex("Name");
b.ToTable("Sites", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Municipality", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TimeZoneId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Municipalities", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("DefaultRatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<DateOnly>("EffectiveFrom")
.HasColumnType("date");
b.Property<DateOnly?>("EffectiveTo")
.HasColumnType("date");
b.Property<decimal>("FixedMonthlyCharge")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("VatPercentage")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.HasKey("Id");
b.HasIndex("MunicipalityId", "EffectiveFrom");
b.ToTable("Tariffs", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.TariffPeriod", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("DaysOfWeek")
.HasColumnType("integer");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("RatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("TariffId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TariffId");
b.ToTable("TariffPeriods", "app");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Device", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Monitoring.Site", "Site")
.WithMany("Devices")
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Site");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.PowerMeasurement", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Monitoring.Device", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Municipality", "Municipality")
.WithMany()
.HasForeignKey("MunicipalityId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Municipality");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Municipality", "Municipality")
.WithMany("Tariffs")
.HasForeignKey("MunicipalityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Municipality");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.TariffPeriod", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Tariff", "Tariff")
.WithMany("Periods")
.HasForeignKey("TariffId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tariff");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Municipality", b =>
{
b.Navigation("Tariffs");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.Navigation("Periods");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,510 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "identity");
migrationBuilder.EnsureSchema(
name: "monitoring");
migrationBuilder.EnsureSchema(
name: "app");
migrationBuilder.CreateTable(
name: "AspNetRoles",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
schema: "identity",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Municipalities",
schema: "app",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TimeZoneId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Municipalities", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WhiteLabelSettings",
schema: "app",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
ApplicationName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
LogoUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
PrimaryColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
SecondaryColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
AccentColor = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
FooterText = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WhiteLabelSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
schema: "identity",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
schema: "identity",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalSchema: "identity",
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
schema: "identity",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalSchema: "identity",
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Sites",
schema: "monitoring",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Address = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
MunicipalityId = table.Column<int>(type: "integer", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sites", x => x.Id);
table.ForeignKey(
name: "FK_Sites_Municipalities_MunicipalityId",
column: x => x.MunicipalityId,
principalSchema: "app",
principalTable: "Municipalities",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Tariffs",
schema: "app",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MunicipalityId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
EffectiveFrom = table.Column<DateOnly>(type: "date", nullable: false),
EffectiveTo = table.Column<DateOnly>(type: "date", nullable: true),
DefaultRatePerKwh = table.Column<decimal>(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false),
FixedMonthlyCharge = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
VatPercentage = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tariffs", x => x.Id);
table.ForeignKey(
name: "FK_Tariffs_Municipalities_MunicipalityId",
column: x => x.MunicipalityId,
principalSchema: "app",
principalTable: "Municipalities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Devices",
schema: "monitoring",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SiteId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Devices", x => x.Id);
table.ForeignKey(
name: "FK_Devices_Sites_SiteId",
column: x => x.SiteId,
principalSchema: "monitoring",
principalTable: "Sites",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "TariffPeriods",
schema: "app",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TariffId = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
DaysOfWeek = table.Column<int>(type: "integer", nullable: false),
StartTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
EndTime = table.Column<TimeOnly>(type: "time without time zone", nullable: false),
RatePerKwh = table.Column<decimal>(type: "numeric(10,4)", precision: 10, scale: 4, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TariffPeriods", x => x.Id);
table.ForeignKey(
name: "FK_TariffPeriods_Tariffs_TariffId",
column: x => x.TariffId,
principalSchema: "app",
principalTable: "Tariffs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PowerMeasurements",
schema: "monitoring",
columns: table => new
{
Time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeviceId = table.Column<Guid>(type: "uuid", nullable: false),
ActivePowerKw = table.Column<double>(type: "double precision", nullable: false),
ReactivePowerKvar = table.Column<double>(type: "double precision", nullable: true),
ApparentPowerKva = table.Column<double>(type: "double precision", nullable: true),
PowerFactor = table.Column<double>(type: "double precision", nullable: true),
VoltageV = table.Column<double>(type: "double precision", nullable: true),
FrequencyHz = table.Column<double>(type: "double precision", nullable: true),
EnergyImportedKwh = table.Column<double>(type: "double precision", nullable: true),
EnergyExportedKwh = table.Column<double>(type: "double precision", nullable: true),
Source = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PowerMeasurements", x => new { x.Time, x.DeviceId });
table.ForeignKey(
name: "FK_PowerMeasurements_Devices_DeviceId",
column: x => x.DeviceId,
principalSchema: "monitoring",
principalTable: "Devices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
schema: "identity",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
schema: "identity",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
schema: "identity",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
schema: "identity",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
schema: "identity",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
schema: "identity",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
schema: "identity",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Devices_ExternalId",
schema: "monitoring",
table: "Devices",
column: "ExternalId");
migrationBuilder.CreateIndex(
name: "IX_Devices_SiteId_ExternalId",
schema: "monitoring",
table: "Devices",
columns: new[] { "SiteId", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Municipalities_Name",
schema: "app",
table: "Municipalities",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PowerMeasurements_DeviceId_Time",
schema: "monitoring",
table: "PowerMeasurements",
columns: new[] { "DeviceId", "Time" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_Sites_MunicipalityId",
schema: "monitoring",
table: "Sites",
column: "MunicipalityId");
migrationBuilder.CreateIndex(
name: "IX_Sites_Name",
schema: "monitoring",
table: "Sites",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_TariffPeriods_TariffId",
schema: "app",
table: "TariffPeriods",
column: "TariffId");
migrationBuilder.CreateIndex(
name: "IX_Tariffs_MunicipalityId_EffectiveFrom",
schema: "app",
table: "Tariffs",
columns: new[] { "MunicipalityId", "EffectiveFrom" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserClaims",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserLogins",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserRoles",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUserTokens",
schema: "identity");
migrationBuilder.DropTable(
name: "PowerMeasurements",
schema: "monitoring");
migrationBuilder.DropTable(
name: "TariffPeriods",
schema: "app");
migrationBuilder.DropTable(
name: "WhiteLabelSettings",
schema: "app");
migrationBuilder.DropTable(
name: "AspNetRoles",
schema: "identity");
migrationBuilder.DropTable(
name: "AspNetUsers",
schema: "identity");
migrationBuilder.DropTable(
name: "Devices",
schema: "monitoring");
migrationBuilder.DropTable(
name: "Tariffs",
schema: "app");
migrationBuilder.DropTable(
name: "Sites",
schema: "monitoring");
migrationBuilder.DropTable(
name: "Municipalities",
schema: "app");
}
}
}

View File

@ -0,0 +1,628 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Tau.Acuvim.Portal.Data;
#nullable disable
namespace Tau.Acuvim.Portal.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("app")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", "identity");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Branding.WhiteLabelSettings", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<string>("AccentColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ApplicationName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FooterText")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LogoUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PrimaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("SecondaryColor")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("WhiteLabelSettings", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", "identity");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Device", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("SiteId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ExternalId");
b.HasIndex("SiteId", "ExternalId")
.IsUnique();
b.ToTable("Devices", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.PowerMeasurement", b =>
{
b.Property<DateTime>("Time")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DeviceId")
.HasColumnType("uuid");
b.Property<double>("ActivePowerKw")
.HasColumnType("double precision");
b.Property<double?>("ApparentPowerKva")
.HasColumnType("double precision");
b.Property<double?>("EnergyExportedKwh")
.HasColumnType("double precision");
b.Property<double?>("EnergyImportedKwh")
.HasColumnType("double precision");
b.Property<double?>("FrequencyHz")
.HasColumnType("double precision");
b.Property<double?>("PowerFactor")
.HasColumnType("double precision");
b.Property<double?>("ReactivePowerKvar")
.HasColumnType("double precision");
b.Property<string>("Source")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<double?>("VoltageV")
.HasColumnType("double precision");
b.HasKey("Time", "DeviceId");
b.HasIndex("DeviceId", "Time")
.IsDescending(false, true);
b.ToTable("PowerMeasurements", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int?>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("MunicipalityId");
b.HasIndex("Name");
b.ToTable("Sites", "monitoring");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Municipality", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TimeZoneId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Municipalities", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("DefaultRatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<DateOnly>("EffectiveFrom")
.HasColumnType("date");
b.Property<DateOnly?>("EffectiveTo")
.HasColumnType("date");
b.Property<decimal>("FixedMonthlyCharge")
.HasPrecision(10, 2)
.HasColumnType("numeric(10,2)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<int>("MunicipalityId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<decimal>("VatPercentage")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.HasKey("Id");
b.HasIndex("MunicipalityId", "EffectiveFrom");
b.ToTable("Tariffs", "app");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.TariffPeriod", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("DaysOfWeek")
.HasColumnType("integer");
b.Property<TimeOnly>("EndTime")
.HasColumnType("time without time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("RatePerKwh")
.HasPrecision(10, 4)
.HasColumnType("numeric(10,4)");
b.Property<TimeOnly>("StartTime")
.HasColumnType("time without time zone");
b.Property<int>("TariffId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TariffId");
b.ToTable("TariffPeriods", "app");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Device", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Monitoring.Site", "Site")
.WithMany("Devices")
.HasForeignKey("SiteId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Site");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.PowerMeasurement", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Monitoring.Device", "Device")
.WithMany()
.HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Device");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Municipality", "Municipality")
.WithMany()
.HasForeignKey("MunicipalityId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Municipality");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Municipality", "Municipality")
.WithMany("Tariffs")
.HasForeignKey("MunicipalityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Municipality");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.TariffPeriod", b =>
{
b.HasOne("Tau.Acuvim.Portal.Domain.Rates.Tariff", "Tariff")
.WithMany("Periods")
.HasForeignKey("TariffId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Tariff");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Monitoring.Site", b =>
{
b.Navigation("Devices");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Municipality", b =>
{
b.Navigation("Tariffs");
});
modelBuilder.Entity("Tau.Acuvim.Portal.Domain.Rates.Tariff", b =>
{
b.Navigation("Periods");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,231 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using Serilog;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Identity;
using Tau.Acuvim.Portal.Endpoints;
using Tau.Acuvim.Portal.Services;
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.Sources.Insert(0, new Microsoft.Extensions.Configuration.Json.JsonConfigurationSource
{
Path = "appsettings.template.json",
Optional = false,
ReloadOnChange = false
});
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.Console());
builder.Services.Configure<ApplicationOptions>(builder.Configuration.GetSection(ApplicationOptions.SectionName));
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection(DatabaseOptions.SectionName));
builder.Services.Configure<TimescaleDbOptions>(builder.Configuration.GetSection(TimescaleDbOptions.SectionName));
builder.Services.Configure<GrafanaOptions>(builder.Configuration.GetSection(GrafanaOptions.SectionName));
builder.Services.Configure<WhiteLabelOptions>(builder.Configuration.GetSection(WhiteLabelOptions.SectionName));
builder.Services.Configure<AuthenticationOptions>(builder.Configuration.GetSection(AuthenticationOptions.SectionName));
builder.Services.Configure<MonitoringOptions>(builder.Configuration.GetSection(MonitoringOptions.SectionName));
var authOptions = builder.Configuration.GetSection(AuthenticationOptions.SectionName).Get<AuthenticationOptions>()
?? new AuthenticationOptions();
var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get<DatabaseOptions>()
?? new DatabaseOptions();
var timescaleOptions = builder.Configuration.GetSection(TimescaleDbOptions.SectionName).Get<TimescaleDbOptions>()
?? new TimescaleDbOptions();
var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get<WhiteLabelOptions>()
?? new WhiteLabelOptions();
var resolution = ConnectionStringResolver.Resolve(databaseOptions, timescaleOptions, builder.Environment);
Log.Information("Database connection resolved via {Source}", resolution.Source);
builder.Services.AddSingleton(new DatabaseResolutionInfo(resolution.Source));
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(resolution.ConnectionString));
builder.Services
.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = authOptions.RequireConfirmedEmail;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = authOptions.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
? CookieSecurePolicy.SameAsRequest
: CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin));
});
// Persist DataProtection keys to a mounted volume so auth cookies survive container restarts.
// /data/keys is created + chowned to the app user in the Dockerfile; the dev and prod
// compose files mount portal-keys there. Skip silently if the path doesn't exist
// (local `dotnet run` outside Docker — keys go to the default user-profile location).
var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys";
if (Directory.Exists(keyRingPath))
{
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(keyRingPath))
.SetApplicationName("Tau.Acuvim.Portal");
}
builder.Services.AddScoped<BrandingService>();
builder.Services.AddScoped<RateService>();
builder.Services.AddSingleton<CostCalculator>();
builder.Services.AddScoped<IdentityBootstrapper>();
builder.Services.AddScoped<TimescaleBootstrapper>();
builder.Services.AddScoped<MeasurementIngestService>();
builder.Services.AddScoped<MeasurementQueryService>();
builder.Services.AddSingleton<GrafanaService>();
builder.Services.AddScoped<ConfigOverviewService>();
builder.Services.AddHealthChecks()
.AddNpgSql(resolution.ConnectionString, name: "timescaledb", tags: new[] { "ready" });
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "Tau Acuvim Portal API", Version = "v1" });
});
builder.Services.AddCors(options =>
{
options.AddPolicy("DevSpa", policy => policy
.WithOrigins("http://localhost:5174")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
var app = builder.Build();
if (app.Environment.IsProduction())
{
var resolved = app.Services.GetRequiredService<IOptions<AuthenticationOptions>>().Value;
if (string.Equals(resolved.DefaultAdminPassword, "ChangeMe123!", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Authentication:DefaultAdminPassword is still the template default. " +
"Set it via env var Authentication__DefaultAdminPassword before running in Production.");
}
}
if (databaseOptions.MigrateOnStartup)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
var timescale = scope.ServiceProvider.GetRequiredService<TimescaleBootstrapper>();
await timescale.EnsureHypertablesAsync();
var bootstrapper = scope.ServiceProvider.GetRequiredService<IdentityBootstrapper>();
await bootstrapper.SeedAsync();
}
Directory.CreateDirectory(whiteLabelOptions.LogoStoragePath);
app.UseSerilogRequestLogging();
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "SAMEORIGIN";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
await next();
});
if (app.Environment.IsDevelopment())
{
app.UseCors("DevSpa");
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Tau Acuvim Portal API"));
}
else
{
app.UseHsts();
}
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(Path.GetFullPath(whiteLabelOptions.LogoStoragePath)),
RequestPath = "/branding-assets"
});
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints();
app.MapAdminUserEndpoints();
app.MapBrandingEndpoints();
app.MapRatesEndpoints();
app.MapAdminRatesEndpoints();
app.MapSitesEndpoints();
app.MapMeasurementsEndpoints();
app.MapGrafanaEndpoints();
app.MapAdminConfigEndpoints();
app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false
}).AllowAnonymous();
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
}).AllowAnonymous();
app.MapFallbackToFile("index.html");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Portal terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}

View File

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"Tau.Acuvim.Portal": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Branding;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class BrandingService(
AppDbContext db,
IOptions<WhiteLabelOptions> defaults)
{
public async Task<BrandingDto> GetAsync(CancellationToken ct = default)
{
var row = await db.WhiteLabelSettings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct);
row ??= await SeedAsync(ct);
return ToDto(row);
}
public async Task<BrandingDto> UpdateAsync(UpdateBrandingRequest req, CancellationToken ct = default)
{
var row = await db.WhiteLabelSettings
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct)
?? await SeedAsync(ct);
row.ApplicationName = string.IsNullOrWhiteSpace(req.ApplicationName) ? defaults.Value.ApplicationName : req.ApplicationName;
row.PrimaryColor = string.IsNullOrWhiteSpace(req.PrimaryColor) ? defaults.Value.PrimaryColor : req.PrimaryColor;
row.SecondaryColor = string.IsNullOrWhiteSpace(req.SecondaryColor) ? defaults.Value.SecondaryColor : req.SecondaryColor;
row.AccentColor = string.IsNullOrWhiteSpace(req.AccentColor) ? defaults.Value.AccentColor : req.AccentColor;
row.FooterText = req.FooterText ?? string.Empty;
if (req.LogoUrl is not null) row.LogoUrl = req.LogoUrl;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return ToDto(row);
}
public async Task<string> SetLogoUrlAsync(string logoUrl, CancellationToken ct = default)
{
var row = await db.WhiteLabelSettings
.FirstOrDefaultAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct)
?? await SeedAsync(ct);
row.LogoUrl = logoUrl;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return logoUrl;
}
public async Task EnsureSeededAsync(CancellationToken ct = default)
{
var exists = await db.WhiteLabelSettings
.AnyAsync(x => x.Id == WhiteLabelSettings.SingletonId, ct);
if (!exists) await SeedAsync(ct);
}
private async Task<WhiteLabelSettings> SeedAsync(CancellationToken ct)
{
var d = defaults.Value;
var row = new WhiteLabelSettings
{
Id = WhiteLabelSettings.SingletonId,
ApplicationName = d.ApplicationName,
LogoUrl = d.LogoUrl,
PrimaryColor = d.PrimaryColor,
SecondaryColor = d.SecondaryColor,
AccentColor = d.AccentColor,
FooterText = d.FooterText,
UpdatedAt = DateTime.UtcNow
};
db.WhiteLabelSettings.Add(row);
await db.SaveChangesAsync(ct);
return row;
}
private static BrandingDto ToDto(WhiteLabelSettings row) => new(
row.ApplicationName,
row.LogoUrl,
row.PrimaryColor,
row.SecondaryColor,
row.AccentColor,
row.FooterText);
}

View File

@ -0,0 +1,82 @@
using Microsoft.Extensions.Options;
using Npgsql;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
// Captures the DB connection-resolution source at startup so the App-config view
// can show how the running container resolved its DB without re-parsing config.
public sealed class DatabaseResolutionInfo
{
public DatabaseResolutionInfo(string source) => Source = source;
public string Source { get; }
public DateTime StartedAtUtc { get; } = DateTime.UtcNow;
}
public sealed class ConfigOverviewService(
IOptions<ApplicationOptions> appOptions,
IOptions<DatabaseOptions> dbOptions,
IOptions<GrafanaOptions> grafanaOptions,
IOptions<MonitoringOptions> monitoringOptions,
IOptions<AuthenticationOptions> authOptions,
IHostEnvironment env,
DatabaseResolutionInfo dbResolution)
{
public ConfigOverviewDto Build()
{
var (host, port, database) = ParseConnection(dbOptions.Value.ConnectionString);
var application = new AppInfoDto(
appOptions.Value.Name,
env.EnvironmentName,
appOptions.Value.PublicUrl);
var database_ = new DatabaseInfoDto(
dbOptions.Value.Provider,
host, port, database,
dbOptions.Value.MigrateOnStartup,
dbOptions.Value.AutoProvisionLocalTimescaleDb,
dbResolution.Source);
var grafana = new GrafanaInfoDto(
grafanaOptions.Value.BaseUrl,
grafanaOptions.Value.InternalUrl,
grafanaOptions.Value.EmbedPathPrefix,
grafanaOptions.Value.EmbedMode,
grafanaOptions.Value.DefaultDashboardUid,
grafanaOptions.Value.AuthMode,
grafanaOptions.Value.Dashboards.Count);
var monitoring = new MonitoringInfoDto(
monitoringOptions.Value.ChunkTimeInterval,
monitoringOptions.Value.EnableHourlyAggregates);
var auth = new AuthInfoDto(
authOptions.Value.CookieName,
authOptions.Value.RequireConfirmedEmail,
authOptions.Value.DefaultAdminEmail);
var assembly = typeof(ConfigOverviewService).Assembly.GetName();
var build = new BuildInfoDto(
assembly.Version?.ToString() ?? "0.0.0",
Environment.Version.ToString(),
dbResolution.StartedAtUtc);
return new ConfigOverviewDto(application, database_, grafana, monitoring, auth, build);
}
private static (string Host, int Port, string Database) ParseConnection(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString)) return ("(unset)", 0, "(unset)");
try
{
var b = new NpgsqlConnectionStringBuilder(connectionString);
return (b.Host ?? "(unset)", b.Port, b.Database ?? "(unset)");
}
catch
{
return ("(unparseable)", 0, "(unparseable)");
}
}
}

View File

@ -0,0 +1,53 @@
using Tau.Acuvim.Portal.Domain.Rates;
namespace Tau.Acuvim.Portal.Services;
// Pure. No DI, no DB, no clock. Phase 11 unit-tests this directly.
public sealed class CostCalculator
{
public CostBreakdown Calculate(
Tariff tariff,
IEnumerable<ConsumptionSample> samples,
bool includeFixedMonthlyCharge = true,
TimeZoneInfo? timeZone = null)
{
ArgumentNullException.ThrowIfNull(tariff);
ArgumentNullException.ThrowIfNull(samples);
var tz = timeZone ?? TimeZoneInfo.Utc;
var orderedPeriods = tariff.Periods.OrderBy(p => p.StartTime).ToList();
decimal baseCost = 0m;
foreach (var sample in samples)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(
DateTime.SpecifyKind(sample.TimestampUtc, DateTimeKind.Utc), tz);
var rate = SelectRate(orderedPeriods, local, tariff.DefaultRatePerKwh);
baseCost += sample.Kwh * rate;
}
var fixedCharges = includeFixedMonthlyCharge ? tariff.FixedMonthlyCharge : 0m;
var subtotal = Round(baseCost) + fixedCharges;
var vat = Round(subtotal * tariff.VatPercentage / 100m);
var total = subtotal + vat;
return new CostBreakdown(Round(baseCost), fixedCharges, subtotal, vat, total);
}
private static decimal SelectRate(
IReadOnlyList<TariffPeriod> orderedPeriods,
DateTime localTime,
decimal defaultRate)
{
var flag = localTime.DayOfWeek.ToFlag();
var t = TimeOnly.FromDateTime(localTime);
foreach (var p in orderedPeriods)
{
if ((p.DaysOfWeek & flag) == 0) continue;
if (t >= p.StartTime && t < p.EndTime) return p.RatePerKwh;
}
return defaultRate;
}
private static decimal Round(decimal v) => Math.Round(v, 4, MidpointRounding.AwayFromZero);
}

View File

@ -0,0 +1,18 @@
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class GrafanaService(IOptions<GrafanaOptions> options)
{
public GrafanaConfigDto GetConfig()
{
var opts = options.Value;
var dashboards = opts.Dashboards
.Where(d => !string.IsNullOrWhiteSpace(d.Uid))
.Select(d => new GrafanaDashboardDto(d.Uid, d.Title, d.Description))
.ToArray();
return new GrafanaConfigDto(opts.BaseUrl, opts.DefaultDashboardUid, dashboards);
}
}

View File

@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Constants;
using Tau.Acuvim.Portal.Domain.Identity;
namespace Tau.Acuvim.Portal.Services;
public sealed class IdentityBootstrapper(
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
BrandingService branding,
IOptions<AuthenticationOptions> auth,
ILogger<IdentityBootstrapper> log)
{
public async Task SeedAsync(CancellationToken ct = default)
{
await branding.EnsureSeededAsync(ct);
if (!await roles.RoleExistsAsync(Roles.Admin))
{
var roleResult = await roles.CreateAsync(new IdentityRole(Roles.Admin));
if (!roleResult.Succeeded)
{
log.LogError("Failed to seed {Role} role: {Errors}",
Roles.Admin, string.Join("; ", roleResult.Errors.Select(e => e.Description)));
return;
}
}
var adminEmail = auth.Value.DefaultAdminEmail;
var existing = await users.FindByEmailAsync(adminEmail);
if (existing is not null) return;
var admin = new ApplicationUser
{
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
DisplayName = "Administrator",
IsActive = true,
CreatedAt = DateTime.UtcNow
};
var createResult = await users.CreateAsync(admin, auth.Value.DefaultAdminPassword);
if (!createResult.Succeeded)
{
log.LogError("Failed to seed default admin {Email}: {Errors}",
adminEmail, string.Join("; ", createResult.Errors.Select(e => e.Description)));
return;
}
var roleAssign = await users.AddToRoleAsync(admin, Roles.Admin);
if (!roleAssign.Succeeded)
{
log.LogError("Failed to assign Admin role to {Email}: {Errors}",
adminEmail, string.Join("; ", roleAssign.Errors.Select(e => e.Description)));
return;
}
log.LogInformation("Seeded default admin {Email}", adminEmail);
}
}

View File

@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class MeasurementIngestService(AppDbContext db, ILogger<MeasurementIngestService> log)
{
private const int BatchSize = 500;
public async Task<IngestResultDto> IngestAsync(
IReadOnlyList<MeasurementIngestRow> rows,
CancellationToken ct = default)
{
if (rows.Count == 0) return new IngestResultDto(0, 0);
var externalIds = rows.Select(r => r.DeviceExternalId).Distinct().ToArray();
var deviceMap = await db.Devices
.Where(d => externalIds.Contains(d.ExternalId))
.Select(d => new { d.ExternalId, d.Id })
.ToDictionaryAsync(x => x.ExternalId, x => x.Id, ct);
int accepted = 0;
int rejected = 0;
var batch = new List<MeasurementIngestRow>(BatchSize);
foreach (var row in rows)
{
if (!deviceMap.ContainsKey(row.DeviceExternalId))
{
rejected++;
continue;
}
batch.Add(row);
if (batch.Count >= BatchSize)
{
accepted += await InsertBatchAsync(batch, deviceMap, ct);
batch.Clear();
}
}
if (batch.Count > 0)
{
accepted += await InsertBatchAsync(batch, deviceMap, ct);
}
if (rejected > 0)
{
log.LogWarning("Rejected {Rejected} measurements with unknown device external IDs", rejected);
}
return new IngestResultDto(accepted, rejected);
}
private async Task<int> InsertBatchAsync(
IReadOnlyList<MeasurementIngestRow> batch,
IReadOnlyDictionary<string, Guid> deviceMap,
CancellationToken ct)
{
var sql = new System.Text.StringBuilder(
"""
INSERT INTO monitoring."PowerMeasurements"
("Time","DeviceId","ActivePowerKw","ReactivePowerKvar","ApparentPowerKva",
"PowerFactor","VoltageV","FrequencyHz","EnergyImportedKwh","EnergyExportedKwh","Source")
VALUES
""");
var parameters = new List<Npgsql.NpgsqlParameter>(batch.Count * 11);
for (int i = 0; i < batch.Count; i++)
{
var row = batch[i];
if (i > 0) sql.Append(',');
int b = i * 11;
sql.Append($" (@p{b},@p{b + 1},@p{b + 2},@p{b + 3},@p{b + 4},@p{b + 5},@p{b + 6},@p{b + 7},@p{b + 8},@p{b + 9},@p{b + 10})");
parameters.Add(new($"p{b}", DateTime.SpecifyKind(row.Time, DateTimeKind.Utc)));
parameters.Add(new($"p{b + 1}", deviceMap[row.DeviceExternalId]));
parameters.Add(new($"p{b + 2}", row.ActivePowerKw));
parameters.Add(new($"p{b + 3}", (object?)row.ReactivePowerKvar ?? DBNull.Value));
parameters.Add(new($"p{b + 4}", (object?)row.ApparentPowerKva ?? DBNull.Value));
parameters.Add(new($"p{b + 5}", (object?)row.PowerFactor ?? DBNull.Value));
parameters.Add(new($"p{b + 6}", (object?)row.VoltageV ?? DBNull.Value));
parameters.Add(new($"p{b + 7}", (object?)row.FrequencyHz ?? DBNull.Value));
parameters.Add(new($"p{b + 8}", (object?)row.EnergyImportedKwh ?? DBNull.Value));
parameters.Add(new($"p{b + 9}", (object?)row.EnergyExportedKwh ?? DBNull.Value));
parameters.Add(new($"p{b + 10}", (object?)row.Source ?? DBNull.Value));
}
sql.Append(" ON CONFLICT (\"Time\",\"DeviceId\") DO NOTHING;");
return await db.Database.ExecuteSqlRawAsync(sql.ToString(), parameters, ct);
}
}

View File

@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class MeasurementQueryService(AppDbContext db)
{
private static readonly Dictionary<string, string> AllowedBuckets = new(StringComparer.OrdinalIgnoreCase)
{
["minute"] = "1 minute",
["5min"] = "5 minutes",
["15min"] = "15 minutes",
["hour"] = "1 hour",
["day"] = "1 day",
};
public bool IsValidBucket(string bucket) => AllowedBuckets.ContainsKey(bucket);
public async Task<List<MeasurementBucketDto>> GetBucketsAsync(
Guid deviceId, DateTime fromUtc, DateTime toUtc, string bucket, CancellationToken ct = default)
{
if (!AllowedBuckets.TryGetValue(bucket, out var interval))
{
throw new ArgumentException($"Unsupported bucket '{bucket}'.", nameof(bucket));
}
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync(ct);
}
await using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT
time_bucket(INTERVAL '{interval}', "Time") AS bucket,
avg("ActivePowerKw") AS avg_kw,
max("ActivePowerKw") AS max_kw,
min("ActivePowerKw") AS min_kw,
max("EnergyImportedKwh") - min("EnergyImportedKwh") AS imp_delta,
max("EnergyExportedKwh") - min("EnergyExportedKwh") AS exp_delta,
count(*) AS samples
FROM monitoring."PowerMeasurements"
WHERE "DeviceId" = @deviceId
AND "Time" >= @fromUtc
AND "Time" < @toUtc
GROUP BY bucket
ORDER BY bucket;
""";
cmd.Parameters.Add(new NpgsqlParameter("@deviceId", deviceId));
cmd.Parameters.Add(new NpgsqlParameter("@fromUtc", DateTime.SpecifyKind(fromUtc, DateTimeKind.Utc)));
cmd.Parameters.Add(new NpgsqlParameter("@toUtc", DateTime.SpecifyKind(toUtc, DateTimeKind.Utc)));
var results = new List<MeasurementBucketDto>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
results.Add(new MeasurementBucketDto(
BucketStart: reader.GetDateTime(0),
AvgActivePowerKw: reader.GetDouble(1),
MaxActivePowerKw: reader.GetDouble(2),
MinActivePowerKw: reader.GetDouble(3),
EnergyImportedKwhDelta: reader.IsDBNull(4) ? null : reader.GetDouble(4),
EnergyExportedKwhDelta: reader.IsDBNull(5) ? null : reader.GetDouble(5),
SampleCount: reader.GetInt32(6)));
}
return results;
}
}

View File

@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.Domain.Rates;
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
public sealed class RateService(AppDbContext db)
{
public async Task<List<MunicipalityDto>> ListMunicipalitiesAsync(CancellationToken ct = default) =>
await db.Municipalities
.OrderBy(m => m.Name)
.Select(m => new MunicipalityDto(m.Id, m.Name, m.TimeZoneId, m.IsActive, m.Tariffs.Count))
.ToListAsync(ct);
public async Task<Municipality> CreateMunicipalityAsync(CreateMunicipalityRequest req, CancellationToken ct = default)
{
var m = new Municipality
{
Name = req.Name.Trim(),
TimeZoneId = NormalizeTz(req.TimeZoneId),
IsActive = req.IsActive
};
db.Municipalities.Add(m);
await db.SaveChangesAsync(ct);
return m;
}
public async Task<bool> UpdateMunicipalityAsync(int id, UpdateMunicipalityRequest req, CancellationToken ct = default)
{
var m = await db.Municipalities.FindAsync(new object?[] { id }, ct);
if (m is null) return false;
m.Name = req.Name.Trim();
m.TimeZoneId = NormalizeTz(req.TimeZoneId);
m.IsActive = req.IsActive;
await db.SaveChangesAsync(ct);
return true;
}
public async Task<bool> DeleteMunicipalityAsync(int id, CancellationToken ct = default)
{
var m = await db.Municipalities.FindAsync(new object?[] { id }, ct);
if (m is null) return false;
db.Municipalities.Remove(m);
await db.SaveChangesAsync(ct);
return true;
}
public async Task<List<TariffSummaryDto>> ListTariffsAsync(int municipalityId, CancellationToken ct = default) =>
await db.Tariffs
.Where(t => t.MunicipalityId == municipalityId)
.OrderByDescending(t => t.EffectiveFrom)
.Select(t => new TariffSummaryDto(
t.Id, t.Name, t.EffectiveFrom, t.EffectiveTo,
t.DefaultRatePerKwh, t.FixedMonthlyCharge, t.VatPercentage,
t.IsActive, t.Periods.Count))
.ToListAsync(ct);
public async Task<TariffDetailDto?> GetTariffAsync(int tariffId, CancellationToken ct = default)
{
var t = await db.Tariffs.Include(x => x.Periods)
.FirstOrDefaultAsync(x => x.Id == tariffId, ct);
return t is null ? null : ToDetail(t);
}
public async Task<TariffDetailDto?> GetActiveTariffAsync(int municipalityId, DateTime atUtc, CancellationToken ct = default)
{
var day = DateOnly.FromDateTime(atUtc);
var t = await db.Tariffs.Include(x => x.Periods)
.Where(x => x.MunicipalityId == municipalityId
&& x.IsActive
&& x.EffectiveFrom <= day
&& (x.EffectiveTo == null || x.EffectiveTo >= day))
.OrderByDescending(x => x.EffectiveFrom)
.FirstOrDefaultAsync(ct);
return t is null ? null : ToDetail(t);
}
public async Task<TariffDetailDto> CreateTariffAsync(int municipalityId, UpsertTariffRequest req, CancellationToken ct = default)
{
var t = new Tariff
{
MunicipalityId = municipalityId,
Name = req.Name.Trim(),
EffectiveFrom = req.EffectiveFrom,
EffectiveTo = req.EffectiveTo,
DefaultRatePerKwh = req.DefaultRatePerKwh,
FixedMonthlyCharge = req.FixedMonthlyCharge,
VatPercentage = req.VatPercentage,
IsActive = req.IsActive,
Periods = MapPeriods(req.Periods).ToList()
};
db.Tariffs.Add(t);
await db.SaveChangesAsync(ct);
return ToDetail(t);
}
public async Task<TariffDetailDto?> UpdateTariffAsync(int tariffId, UpsertTariffRequest req, CancellationToken ct = default)
{
var t = await db.Tariffs.Include(x => x.Periods).FirstOrDefaultAsync(x => x.Id == tariffId, ct);
if (t is null) return null;
t.Name = req.Name.Trim();
t.EffectiveFrom = req.EffectiveFrom;
t.EffectiveTo = req.EffectiveTo;
t.DefaultRatePerKwh = req.DefaultRatePerKwh;
t.FixedMonthlyCharge = req.FixedMonthlyCharge;
t.VatPercentage = req.VatPercentage;
t.IsActive = req.IsActive;
db.TariffPeriods.RemoveRange(t.Periods);
t.Periods = MapPeriods(req.Periods).ToList();
await db.SaveChangesAsync(ct);
return ToDetail(t);
}
public async Task<bool> DeleteTariffAsync(int tariffId, CancellationToken ct = default)
{
var t = await db.Tariffs.FindAsync(new object?[] { tariffId }, ct);
if (t is null) return false;
db.Tariffs.Remove(t);
await db.SaveChangesAsync(ct);
return true;
}
private static IEnumerable<TariffPeriod> MapPeriods(IReadOnlyList<TariffPeriodDto> periods) =>
periods.Select(p => new TariffPeriod
{
Name = p.Name.Trim(),
DaysOfWeek = (DayOfWeekFlag)p.DaysOfWeek,
StartTime = TimeOnly.Parse(p.StartTime),
EndTime = TimeOnly.Parse(p.EndTime),
RatePerKwh = p.RatePerKwh
});
private static TariffDetailDto ToDetail(Tariff t) => new(
t.Id, t.MunicipalityId, t.Name, t.EffectiveFrom, t.EffectiveTo,
t.DefaultRatePerKwh, t.FixedMonthlyCharge, t.VatPercentage, t.IsActive,
t.Periods.OrderBy(p => p.StartTime).Select(p => new TariffPeriodDto(
p.Name, (int)p.DaysOfWeek, p.StartTime.ToString("HH:mm"), p.EndTime.ToString("HH:mm"),
p.RatePerKwh)).ToArray());
private static string? NormalizeTz(string? tz) =>
string.IsNullOrWhiteSpace(tz) ? null : tz.Trim();
}

View File

@ -0,0 +1,31 @@
using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services;
// Pure validator for UpsertTariffRequest. Returns null when valid, otherwise an error string.
public static class TariffValidator
{
public static string? Validate(UpsertTariffRequest req)
{
if (req is null) return "Request body is required.";
if (string.IsNullOrWhiteSpace(req.Name)) return "Tariff name is required.";
if (req.EffectiveTo.HasValue && req.EffectiveTo.Value < req.EffectiveFrom)
return "EffectiveTo must be on or after EffectiveFrom.";
if (req.DefaultRatePerKwh < 0) return "DefaultRatePerKwh must be non-negative.";
if (req.FixedMonthlyCharge < 0) return "FixedMonthlyCharge must be non-negative.";
if (req.VatPercentage < 0 || req.VatPercentage > 100) return "VatPercentage must be 0100.";
foreach (var p in req.Periods)
{
if (string.IsNullOrWhiteSpace(p.Name)) return "Period name is required.";
if (p.DaysOfWeek == 0) return $"Period '{p.Name}' must have at least one day.";
if (!TimeOnly.TryParse(p.StartTime, out var start) || !TimeOnly.TryParse(p.EndTime, out var end))
return $"Period '{p.Name}' has invalid times (HH:mm).";
if (start >= end)
return $"Period '{p.Name}' StartTime must be before EndTime (no midnight wrap; split the window).";
if (p.RatePerKwh < 0)
return $"Period '{p.Name}' RatePerKwh must be non-negative.";
}
return null;
}
}

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Data;
namespace Tau.Acuvim.Portal.Services;
// Runs once on startup after EF migrations. Idempotent.
// 1. CREATE EXTENSION timescaledb (defensive — the timescale/timescaledb image ships with it).
// 2. Convert monitoring."PowerMeasurements" to a hypertable on Time.
// 3. Set chunk time interval from MonitoringOptions.
// 4. If EnableHourlyAggregates is set in config: NO-OP today (Phase 8 deliberately ships the
// flag without the aggregate SQL — adding it later is one SQL block here).
public sealed class TimescaleBootstrapper(
AppDbContext db,
IOptions<MonitoringOptions> options,
ILogger<TimescaleBootstrapper> log)
{
public async Task EnsureHypertablesAsync(CancellationToken ct = default)
{
await db.Database.ExecuteSqlRawAsync(
"CREATE EXTENSION IF NOT EXISTS timescaledb;", ct);
await db.Database.ExecuteSqlRawAsync(
"""
SELECT create_hypertable(
'monitoring."PowerMeasurements"',
'Time',
if_not_exists => TRUE,
migrate_data => TRUE
);
""",
ct);
// Interpolation is safe here: the value is placed inside a single-quoted SQL literal
// and any embedded ' is doubled, so the worst an injected value can do is produce an
// invalid INTERVAL that Postgres rejects. INTERVAL literals can't be passed as parameters.
#pragma warning disable EF1002
await db.Database.ExecuteSqlRawAsync(
$"""
SELECT set_chunk_time_interval(
'monitoring."PowerMeasurements"',
INTERVAL '{options.Value.ChunkTimeInterval.Replace("'", "''")}'
);
""",
ct);
#pragma warning restore EF1002
if (options.Value.EnableHourlyAggregates)
{
log.LogWarning(
"Monitoring.EnableHourlyAggregates=true but continuous aggregates are not implemented in Phase 8. " +
"Flag is reserved for a future phase.");
}
log.LogInformation("TimescaleDB hypertable for monitoring.PowerMeasurements is ready " +
"(chunk interval: {Interval})", options.Value.ChunkTimeInterval);
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Tau.Acuvim.Portal</RootNamespace>
<AssemblyName>Tau.Acuvim.Portal</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More