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:
parent
99864d0a8b
commit
e17921a122
16
portal/.dockerignore
Normal file
16
portal/.dockerignore
Normal 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
28
portal/.env.example
Normal 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
29
portal/.gitignore
vendored
Normal 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
28
portal/Dockerfile
Normal 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
319
portal/OPERATIONS.md
Normal 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
369
portal/README.md
Normal 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
129
portal/TESTING.md
Normal 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 0–100) 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.
|
||||||
111
portal/docker-compose.prod.yml
Normal file
111
portal/docker-compose.prod.yml
Normal 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
70
portal/docker-compose.yml
Normal 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
11
portal/frontend/.gitignore
vendored
Normal 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
|
||||||
12
portal/frontend/index.html
Normal file
12
portal/frontend/index.html
Normal 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
3282
portal/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
portal/frontend/package.json
Normal file
27
portal/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
portal/frontend/src/App.tsx
Normal file
64
portal/frontend/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
portal/frontend/src/api/adminConfig.ts
Normal file
31
portal/frontend/src/api/adminConfig.ts
Normal 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;
|
||||||
|
}
|
||||||
34
portal/frontend/src/api/auth.ts
Normal file
34
portal/frontend/src/api/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
38
portal/frontend/src/api/branding.ts
Normal file
38
portal/frontend/src/api/branding.ts
Normal 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;
|
||||||
|
}
|
||||||
7
portal/frontend/src/api/client.ts
Normal file
7
portal/frontend/src/api/client.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
18
portal/frontend/src/api/grafana.ts
Normal file
18
portal/frontend/src/api/grafana.ts
Normal 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;
|
||||||
|
}
|
||||||
104
portal/frontend/src/api/rates.ts
Normal file
104
portal/frontend/src/api/rates.ts
Normal 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}`);
|
||||||
|
}
|
||||||
69
portal/frontend/src/api/sites.ts
Normal file
69
portal/frontend/src/api/sites.ts
Normal 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}`);
|
||||||
|
}
|
||||||
45
portal/frontend/src/api/users.ts
Normal file
45
portal/frontend/src/api/users.ts
Normal 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}`);
|
||||||
|
}
|
||||||
23
portal/frontend/src/components/RequireAuth.tsx
Normal file
23
portal/frontend/src/components/RequireAuth.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
19
portal/frontend/src/components/RequireRole.tsx
Normal file
19
portal/frontend/src/components/RequireRole.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
19
portal/frontend/src/components/ThemedRoot.tsx
Normal file
19
portal/frontend/src/components/ThemedRoot.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
portal/frontend/src/components/layout/AppLayout.tsx
Normal file
89
portal/frontend/src/components/layout/AppLayout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
portal/frontend/src/components/settings/BrandingForm.tsx
Normal file
192
portal/frontend/src/components/settings/BrandingForm.tsx
Normal 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 '';
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
portal/frontend/src/components/settings/GrafanaInfoCard.tsx
Normal file
81
portal/frontend/src/components/settings/GrafanaInfoCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
portal/frontend/src/components/settings/rates/PeriodEditor.tsx
Normal file
119
portal/frontend/src/components/settings/rates/PeriodEditor.tsx
Normal 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:00–06:00,
|
||||||
|
add two rows (22:00–23:59 and 00:00–06:00).
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
portal/frontend/src/components/settings/rates/TariffDrawer.tsx
Normal file
181
portal/frontend/src/components/settings/rates/TariffDrawer.tsx
Normal 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.';
|
||||||
|
}
|
||||||
76
portal/frontend/src/components/sites/DeviceFormModal.tsx
Normal file
76
portal/frontend/src/components/sites/DeviceFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
portal/frontend/src/components/sites/SiteFormModal.tsx
Normal file
82
portal/frontend/src/components/sites/SiteFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
portal/frontend/src/components/users/UserFormDrawer.tsx
Normal file
103
portal/frontend/src/components/users/UserFormDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
portal/frontend/src/hooks/useAuth.tsx
Normal file
44
portal/frontend/src/hooks/useAuth.tsx
Normal 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;
|
||||||
|
}
|
||||||
47
portal/frontend/src/hooks/useBranding.tsx
Normal file
47
portal/frontend/src/hooks/useBranding.tsx
Normal 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;
|
||||||
|
}
|
||||||
10
portal/frontend/src/main.tsx
Normal file
10
portal/frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
217
portal/frontend/src/pages/AdminSitesPage.tsx
Normal file
217
portal/frontend/src/pages/AdminSitesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
portal/frontend/src/pages/DashboardPage.tsx
Normal file
15
portal/frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
portal/frontend/src/pages/DashboardsPage.tsx
Normal file
87
portal/frontend/src/pages/DashboardsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
portal/frontend/src/pages/LoginPage.tsx
Normal file
87
portal/frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
portal/frontend/src/pages/SettingsPage.tsx
Normal file
23
portal/frontend/src/pages/SettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
portal/frontend/src/pages/UsersPage.tsx
Normal file
198
portal/frontend/src/pages/UsersPage.tsx
Normal 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
1
portal/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
portal/frontend/tsconfig.json
Normal file
22
portal/frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
11
portal/frontend/tsconfig.node.json
Normal file
11
portal/frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
portal/frontend/vite.config.ts
Normal file
23
portal/frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
0
portal/grafana/dashboards/.gitkeep
Normal file
0
portal/grafana/dashboards/.gitkeep
Normal file
150
portal/grafana/dashboards/power-overview.json
Normal file
150
portal/grafana/dashboards/power-overview.json
Normal 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": ""
|
||||||
|
}
|
||||||
13
portal/grafana/provisioning/dashboards/dashboards.yml
Normal file
13
portal/grafana/provisioning/dashboards/dashboards.yml
Normal 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
|
||||||
18
portal/grafana/provisioning/datasources/timescaledb.yml
Normal file
18
portal/grafana/provisioning/datasources/timescaledb.yml
Normal 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
|
||||||
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
74
portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs
Normal file
74
portal/src/Tau.Acuvim.Portal/Configuration/PortalOptions.cs
Normal 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!";
|
||||||
|
}
|
||||||
11
portal/src/Tau.Acuvim.Portal/Constants/Roles.cs
Normal file
11
portal/src/Tau.Acuvim.Portal/Constants/Roles.cs
Normal 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";
|
||||||
|
}
|
||||||
5
portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs
Normal file
5
portal/src/Tau.Acuvim.Portal/DTOs/AuthDtos.cs
Normal 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);
|
||||||
19
portal/src/Tau.Acuvim.Portal/DTOs/BrandingDtos.cs
Normal file
19
portal/src/Tau.Acuvim.Portal/DTOs/BrandingDtos.cs
Normal 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);
|
||||||
35
portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs
Normal file
35
portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs
Normal 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);
|
||||||
8
portal/src/Tau.Acuvim.Portal/DTOs/GrafanaDtos.cs
Normal file
8
portal/src/Tau.Acuvim.Portal/DTOs/GrafanaDtos.cs
Normal 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);
|
||||||
45
portal/src/Tau.Acuvim.Portal/DTOs/MonitoringDtos.cs
Normal file
45
portal/src/Tau.Acuvim.Portal/DTOs/MonitoringDtos.cs
Normal 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);
|
||||||
51
portal/src/Tau.Acuvim.Portal/DTOs/RatesDtos.cs
Normal file
51
portal/src/Tau.Acuvim.Portal/DTOs/RatesDtos.cs
Normal 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);
|
||||||
22
portal/src/Tau.Acuvim.Portal/DTOs/UserDtos.cs
Normal file
22
portal/src/Tau.Acuvim.Portal/DTOs/UserDtos.cs
Normal 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);
|
||||||
105
portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs
Normal file
105
portal/src/Tau.Acuvim.Portal/Data/AppDbContext.cs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
24
portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Device.cs
Normal file
24
portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Device.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
24
portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Site.cs
Normal file
24
portal/src/Tau.Acuvim.Portal/Domain/Monitoring/Site.cs
Normal 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();
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
namespace Tau.Acuvim.Portal.Domain.Rates;
|
||||||
|
|
||||||
|
public sealed record ConsumptionSample(DateTime TimestampUtc, decimal Kwh);
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
namespace Tau.Acuvim.Portal.Domain.Rates;
|
||||||
|
|
||||||
|
public sealed record CostBreakdown(
|
||||||
|
decimal BaseCost,
|
||||||
|
decimal FixedCharges,
|
||||||
|
decimal Subtotal,
|
||||||
|
decimal Vat,
|
||||||
|
decimal TotalCost);
|
||||||
32
portal/src/Tau.Acuvim.Portal/Domain/Rates/DayOfWeekFlag.cs
Normal file
32
portal/src/Tau.Acuvim.Portal/Domain/Rates/DayOfWeekFlag.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
20
portal/src/Tau.Acuvim.Portal/Domain/Rates/Municipality.cs
Normal file
20
portal/src/Tau.Acuvim.Portal/Domain/Rates/Municipality.cs
Normal 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();
|
||||||
|
}
|
||||||
27
portal/src/Tau.Acuvim.Portal/Domain/Rates/Tariff.cs
Normal file
27
portal/src/Tau.Acuvim.Portal/Domain/Rates/Tariff.cs
Normal 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();
|
||||||
|
}
|
||||||
21
portal/src/Tau.Acuvim.Portal/Domain/Rates/TariffPeriod.cs
Normal file
21
portal/src/Tau.Acuvim.Portal/Domain/Rates/TariffPeriod.cs
Normal 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; }
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
portal/src/Tau.Acuvim.Portal/Endpoints/AdminUserEndpoints.cs
Normal file
121
portal/src/Tau.Acuvim.Portal/Endpoints/AdminUserEndpoints.cs
Normal 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() });
|
||||||
|
}
|
||||||
66
portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs
Normal file
66
portal/src/Tau.Acuvim.Portal/Endpoints/AuthEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
portal/src/Tau.Acuvim.Portal/Endpoints/BrandingEndpoints.cs
Normal file
65
portal/src/Tau.Acuvim.Portal/Endpoints/BrandingEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
portal/src/Tau.Acuvim.Portal/Endpoints/GrafanaEndpoints.cs
Normal file
15
portal/src/Tau.Acuvim.Portal/Endpoints/GrafanaEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
portal/src/Tau.Acuvim.Portal/Endpoints/RatesEndpoints.cs
Normal file
35
portal/src/Tau.Acuvim.Portal/Endpoints/RatesEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
portal/src/Tau.Acuvim.Portal/Endpoints/SitesEndpoints.cs
Normal file
123
portal/src/Tau.Acuvim.Portal/Endpoints/SitesEndpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
631
portal/src/Tau.Acuvim.Portal/Migrations/20260518064632_InitialCreate.Designer.cs
generated
Normal file
631
portal/src/Tau.Acuvim.Portal/Migrations/20260518064632_InitialCreate.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
portal/src/Tau.Acuvim.Portal/Program.cs
Normal file
231
portal/src/Tau.Acuvim.Portal/Program.cs
Normal 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();
|
||||||
|
}
|
||||||
13
portal/src/Tau.Acuvim.Portal/Properties/launchSettings.json
Normal file
13
portal/src/Tau.Acuvim.Portal/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs
Normal file
84
portal/src/Tau.Acuvim.Portal/Services/BrandingService.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
portal/src/Tau.Acuvim.Portal/Services/CostCalculator.cs
Normal file
53
portal/src/Tau.Acuvim.Portal/Services/CostCalculator.cs
Normal 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);
|
||||||
|
}
|
||||||
18
portal/src/Tau.Acuvim.Portal/Services/GrafanaService.cs
Normal file
18
portal/src/Tau.Acuvim.Portal/Services/GrafanaService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
146
portal/src/Tau.Acuvim.Portal/Services/RateService.cs
Normal file
146
portal/src/Tau.Acuvim.Portal/Services/RateService.cs
Normal 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();
|
||||||
|
}
|
||||||
31
portal/src/Tau.Acuvim.Portal/Services/TariffValidator.cs
Normal file
31
portal/src/Tau.Acuvim.Portal/Services/TariffValidator.cs
Normal 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 0–100.";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
portal/src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj
Normal file
25
portal/src/Tau.Acuvim.Portal/Tau.Acuvim.Portal.csproj
Normal 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
Loading…
Reference in New Issue
Block a user